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.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"
# }
# }
# }# 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.
| Venue | Source | Type | Description |
|---|---|---|---|
| SDEX | SDEX |
Order Book | Stellar's Classic Order Book (SDEX) |
| AMM | AMM |
Liquidity Pool | Stellar native AMM liquidity pools (Horizon) |
| Soroswap | soroswap_amm |
AMM DEX | Soroswap constant-product automated market maker on Soroban |
| Aquarius | aquarius_amm |
Liquidity Pool | Aquarius AMM liquidity pools |
| DeFindex | defindex_vault |
Vault | DeFindex yield vaults on Soroban, priced per share |
| Phoenix | phoenix_amm |
Liquidity Pool | Phoenix AMM pools on Soroban |
| Method | Path | Description |
|---|---|---|
| GET | /venues/registry |
List registered venues and their network liveness |
| GET | /live |
Liveness probe — no dependency checks (see docs/health.md) |
| GET | /tvl |
Total Value Locked across Stellar DeFi venues |
| GET | /ready |
Readiness probe — boot complete + dependencies reachable |
| GET | /status |
Indexer health |
| GET | /snapshot?network=testnet |
Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object |
| 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/:asset/fiat |
Convert asset price to fiat currency with NGN support (#290) |
| GET | /price/:assetA/:assetB/history?window=1h |
OHLCV history (1m, 5m, 1h, 24h) |
| GET | /pools |
Active AMM pools being watched |
| 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 | /pairs |
Watched trading pairs |
| POST | /pairs |
Add a new trading pair (Admin only) |
| GET | /price/:assetA/:assetB/depth?amount=1000 |
Simulated order-book depth and execution slippage |
| 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 | /impact/:assetA/:assetB?thresholds=1,5,10 |
Adaptive slippage ladder and threshold crossing sizes |
| 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 | /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 | /rates/:asset |
Get supply and borrow rates for an asset across lending venues |
| 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 | /movers |
Get biggest gainers, biggest losers, and volume movers |
| 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 | /correlation |
Compute price correlation matrix across a basket of assets over a window |
| GET | /discovery/resources?type=&payTo=&network=&extensions=&limit=&offset= |
Bazaar catalog of x402-discoverable resources (spec: bazaar) |
| GET | /discovery/search |
Natural language search with ranking and quality evaluation for discoverable resources |
| POST | /webhooks |
Subscribe to a price-threshold webhook |
| DELETE | /webhooks/:id |
Delete a webhook subscription |
| GET | /usage/me |
Usage and quota for the calling API key (requires Authorization: Bearer <key>) |
| GET | /usage |
Get usage and quota for the calling API key (alias for /usage/me) |
| 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) |
| GET | /rates |
Query latest lending and borrow rates across pools |
| GET/POST | /graphql |
GraphQL query/mutation endpoint (GraphiQL IDE at /graphiql, docs: docs/graphql.md) |
Every non-internal route above has a matching entry in 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.
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.
All routes are partitioned into three security and CORS tiers (public, credentialed, and admin) with a split CORS policy — see the Route Classification & CORS Guide.
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 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.
curl "https://api.example.com/price/XLM/USDC?network=mainnet"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.
Available at /graphql with GraphiQL IDE at /graphiql. Real-time price
streaming is available via the priceUpdated subscription.
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.
query {
getPrice(assetA: "XLM", assetB: "USDC") {
price
bestRoute
vwap1h
volume24h
priceChange24h
}
getBestRoute(assetA: "XLM", assetB: "USDC", amount: 500) {
route
sdexPrice
ammPrice
estimatedOutput
slippagePct
recommendation
}
}The GraphQL schema is exported as a canonical Schema Definition Language (SDL) artifact committed at schema.graphql.
- Location:
schema.graphql - Regenerate / Refresh:
npm run graphql:schema:gen
- Drift Guard & Validation: CI runs
src/__tests__/graphqlSchemaDrift.test.tsto assert thatschema.graphqlmatches the code-defined schema insrc/api/graphql.ts, guarantees deterministic output, validates SDL syntax for client generators, and detects breaking schema changes.
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.
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.
# 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 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.
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 for the cardinality notes
(pairs x networks) and a staleness alert.
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:
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 explicit503 Service Unavailable(DB_POOL_EXHAUSTED) rather than generic latency or unhandled timeouts.
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 client works.
subscription {
priceUpdated(pair: "XLM/USDC", network: "mainnet") {
pair
price
ts
network
}
}npm install graphql-ws wsimport { 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();
networkis 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 ownnetworkfield, so an omitted argument is safe if you read that field — and misleading if you do not.
Note: the
pairargument is the canonicalpairKey(alphabetically sorted, e.g.USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM). Use thelistPairsquery 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, seedocs/graphql.md.
npm run mcp:server starts the Bazaar MCP server (src/mcp/server.ts, wired up in 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.
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
For a client that spawns the server itself as a child process — Claude
Desktop, Cursor, or the runnable agent example
(npm run mcp:agent, which prints discovery and a paid call end to end).
npm run mcp:server # reads MCP_TRANSPORT=stdio, talks JSON-RPC on stdin/stdoutPoint 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 uses:
{
"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.
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:
# 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/mcpAuthentication 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:
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:
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.1unless you setMCP_HTTP_HOSTdeliberately. 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-Idis issued and eachPOSTis served by a fresh transport, so a client cannot hold a session open across requests. Every MCP message must be aPOST(GETreturns405, 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.
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:
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.
/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.
Lens gates /price, /pools, and /candles behind x402 micropayments on Stellar (testnet by default). The /status endpoint is free.
/status provides a structured, comprehensive health contract separating process health (runtime container, memory, uptime) from data health (ingestion currency, last ledger, ingest lag).
curl http://localhost:3002/status{
"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
}- 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 carrieserror: "Database unavailable"and nothing more —/statusis public, so the driver's own message (which can contain the host, the user and the connection string) goes to the server log only.
Configure your synthetic monitor / HTTP probe:
# 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"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.
# 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"
# }@x402/fetch wraps the native fetch so a 402 is intercepted, signed, and retried automatically — your application code looks like a normal request.
npm install @x402/fetch @x402/stellarimport { 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=mainneton the server, point the client atstellar:pubnet, and supply a custom Soroban RPC URL vianew ExactStellarScheme(signer, { url: "https://your-rpc..." }). Stellar payments use ledger-based expiration (~12 ledgers ≈ 60s), not timestamps.
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.
Note: the current
GATED_ROUTESmap insrc/middleware/x402.tsgates/price,/pools, and/candlesonly —/graphqlis not gated. If you intend price data from GraphQL to require the same payment as REST, extendGATED_ROUTES(or add a per-resolver guard).
A typed TypeScript client is generated from openapi.yaml with
openapi-typescript and
openapi-fetch.
It is committed at clients/typescript — no type is
hand-written, so a change to the spec is a change to the client.
# Regenerate clients/typescript/src/schema.d.ts from the spec
npm run client:gen
# Type-check the generated client
npm run client:typecheckCI 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.
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; run it
against a local server with npx tsx clients/typescript/src/example.ts.
Detailed system design and data flow diagrams can be found in the Architecture Overview.
Lens also publishes prices: 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 and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json.
Operators: start with the Operations runbook — 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, ingest metrics, websocket backpressure, health probes, GraphQL. The MCP server also advertises read-only resources — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness an agent can cache against.
The oracle relay example shows a minimal Soroban contract plus a Node relay that reads Lens prices and pushes them on chain.
The price alert bot example shows an "if XLM > X notify me" bot built on the WebSocket price stream — see the cookbook walkthrough.
The fastest way to get Lens running locally is with Docker:
# Start Lens, Postgres, and Redis
docker compose up -d
# Check health
docker compose psThe API will be available at http://localhost:3002. Database migrations run automatically on startup.
# 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 devA 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.
| 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
# Seed both networks (default)
npm run seed
# Seed a single network
npm run seed -- --network testnet
npm run seed -- --network mainnetThe 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
After seeding, start the server (npm run dev) and confirm the endpoints
return real data:
# /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": "<seeded-anchor-timestamp>"
# }
# /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": "<seeded-anchor-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": "<request-timestamp>"
# }(Note: timestamp values above reflect the dynamic hourly anchor at seed execution time and request time).
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) |
When a client exceeds their allowance, Lens replies with HTTP status 429 Too Many Requests.
Retry-AfterHeader: Specifies the number of seconds to wait before retrying (e.g.,Retry-After: 42).- Standardized Error Body:
{ "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.
| 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.
- Runtime: Node.js 20 + TypeScript
- API: Fastify + Mercurius (GraphQL)
- Database: PostgreSQL + TimescaleDB
- Cache: Redis
- Queue: BullMQ
- Stellar: @stellar/stellar-sdk
Run against a real running instance (local, staging or production):
SMOKE_MAX_AGE_SECONDS=120 SMOKE_TIMEOUT_MS=8000 npm run smoke -- http://localhost:3002Validate 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.