Lens exposes a high-performance GraphQL API alongside its REST interface, powered by Fastify and mercurius. It aggregates real-time and historical pricing data from the Stellar Classic Decentralized Exchange (SDEE) order book and Automated Market Maker (AMM) liquidity pools.
- Endpoints & Access
- Schema & Types
- Queries & Runnable Examples
- Live Price Subscriptions (
graphql-transport-ws) - Capabilities & Gaps Against REST
- Query depth limit
| 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). Legacy Apollo subscriptions-transport-ws is not supported and will be rejected during connection initialization.
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.
The complete, canonical Schema Definition Language (SDL) artifact is committed at schema.graphql.
It can be regenerated from code definitions using npm run graphql:schema:gen, and is continuously validated against drift in CI.
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]!
screener(filters: ScreenerFilters, sortBy: String = "volume", order: ScreenerSortOrder = DESC, cursor: String, limit: Int = 20): ScreenerPage!
volumes(asset: String!, window: String, network: String): VolumeReport!
spreads(asset: String!, window: String, network: String): SpreadReport!
}
type Subscription {
priceUpdated(pair: String!, network: String): PriceUpdate!
}
getPriceHistoryrange semantics:fromandtoare ISO 8601 timestamps and are exclusive at the upper (to) end — a bucket is returned only if its start is>= fromand< to. This differs from the RESThistoryroute, which is inclusive at both ends and therefore double-counts boundary buckets across adjacent windows.
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. |
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. |
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. |
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". |
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)`.
Retrieves the latest aggregated price, multi-timeframe VWAPs, 24-hour volume, and the current optimal trade route.
query GetAssetPrice($assetA: String!, $assetB: String!) {
getPrice(assetA: $assetA, assetB: $assetB) {
assetA
assetB
pairKey
price
sdexPrice
ammPrice
bestRoute
volume24h
vwap1h
vwap24h
priceChange24h
lastUpdated
confidence
}
}[```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"
}
}
}
Simulates swap execution for an explicit order amount across both NDEX order books and AMM liquidity pools, calculating projected output tokens and slippage.
query GetBestExecutionRoute($assetA: String!, $assetB: String!, $amount: Float!) {
getBestRoute(assetA: $assetA, assetB: $assetB, Amount: $amount) {
route
sdexPrice
ammPrice
estimatedOutput
slippagePct
recommendation
}
}[```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."
}
}
}
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 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
}
}[```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
}
]
}
}
Returns the canonical list of pair keys currently being tracked and priced by the service.
query {
listPairs
}[```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"
]
}
}
Screens tracked pairs by volume, 24-hour change, price and AMM liquidity,
with cursor pagination. It shares its implementation with the REST
GET /screener route: same filters, same sort fields, same cursor semantics
and the same [1, 100] limit clamp (never rejected).
| Argument | Type | Default | Description |
|---|---|---|---|
filters |
ScreenerFilters |
none | Numeric filters (volume, change24hMin, change24hMax, priceMin, priceMax, liquidity); omitted fields do not filter. Each numeric filter is a lower/upper bound as its name says. |
sortBy |
String |
"volume" |
One of volume, change_24h, price, liquidity. |
order |
ScreenerSortOrder |
DESC |
ASC or DESC. |
cursor |
String |
none | Opaque nextCursor from the previous page. |
limit |
Int |
20 |
Page size, clamped to [1, 100]. |
Important
market_cap is rejected, not ignored. Lens has no circulating supply
data to compute a market cap from. Both the marketCap filter and
sortBy: "market_cap" fail the query with an UNSUPPORTED_PARAMETER /
INVALID_INPUT error carrying the same explanation REST gives — never a
silent null. Use liquidity instead.
query ScreenPairs($filters: ScreenerFilters, $sortBy: String, $limit: Int) {
screener(filters: $filters, sortBy: $sortBy, limit: $limit) {
network
hasMore
nextCursor
rows {
pairKey
volume
price
change24h
liquidity
liquidityUnit
}
}
}[```bash
curl -s -X POST http://localhost:3002/graphql
-H "Content-Type: application/json"
-d '{
"query": "query ScreenPairs($filters: ScreenerFilters, $sortBy: String, $limit: Int) { screener(filters: $filters, sortBy: $sortBy, limit: $limit) { network hasMore nextCursor rows { pairKey volume price change24h liquidity liquidityUnit } } }",
"variables": {
"filters": { "volume": 1000, "change24hMin": -10 },
"sortBy": "liquidity",
"limit": 5
}
}'
#### Example Response
```json
{
"data": {
"screener": {
"network": "testnet",
"hasMore": true,
"nextCursor": "eyJib2x1bWUiOjUwMDAwfQ==",
"rows": [
{
"pairKey": "XLM/USDC",
"volume": 1425000.5,
"price": 0.1245,
"change24h": 1.25,
"liquidity": 83000.4,
"liquidityUnit": "USDC"
}
]
}
}
}
To fetch the next page, pass the previous response's nextCursor back as the
cursor argument. nextCursor is null on the last page; hasMore reports
whether another page exists.
GraphQL counterparts of REST GET /volumes/:asset and GET /spreads/:asset.
Each resolver calls the REST route's own implementation (computeVolumes in
src/routes/volumes.ts, computeSpreads in src/routes/spreads.ts), so the
figures cannot drift between the two surfaces.
| Argument | volumes |
spreads |
|---|---|---|
asset |
required, e.g. XLM |
required, e.g. XLM |
window |
24h (default), 7d, 30d |
5m (default), 15m, 1h, 24h |
network |
testnet / mainnet, defaults to the active network |
testnet / mainnet, defaults to the active network |
An unrecognised window or network fails with INVALID_INPUT and the same
message REST returns with its 400. The network answered for is echoed in
network; the two chains are never pooled.
Per-venue results are a typed list of VenueQuote, so a client can select
just the fields it wants per venue:
volumes.venues:venue,volume(in the asset's own units, summed across pairs) andaggregator. This is REST'sbyVenuemap as a list, in REST'svenuesorder. Every other REST field (totals,netVolume,grossVolume,totalVolume, trade counts, trade-size statistics,status,provenance) is returned under the same name.spreads.venues: one entry per (venue, pair), tightest first, withpairKey,bid,ask,mid,spreadBps,spreadPct,observationsandlastSeen.tightestis the first of them, ornullwhen no venue quoted the asset.
Fields that belong to the other query are null on a VenueQuote.
query VenueViews($asset: String!, $network: String) {
volumes(asset: $asset, window: "24h", network: $network) {
network
totals { net gross }
venues { venue volume aggregator }
}
spreads(asset: $asset, window: "5m", network: $network) {
network
tightest { venue pairKey spreadBps }
venues { venue pairKey bid ask spreadBps }
}
}curl -s -X POST http://localhost:3002/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query VenueViews($asset: String!, $network: String) { volumes(asset: $asset, network: $network) { network totals { net gross } venues { venue volume aggregator } } spreads(asset: $asset, network: $network) { network tightest { venue pairKey spreadBps } } }",
"variables": { "asset": "XLM", "network": "testnet" }
}'Live price updates are streamed over WebSocket using the graphql-transport-ws subprotocol. The client must negotiate this subprotocol during the WebSocket handshake.
- Open a WebSocket to
ws://localhost:3002/graphqlwith the subprotocolgraphql-transport-ws. - Send a
connection_init" message. The server replies withconnection_ackon success. - Send a
subscribemessage containing the GraphQL subscription document and variables. - Receive
datamessages as price updates arrive. - Send
completeto unsubscribe, or close the socket.
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.
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);
}
});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.| 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 |