diff --git a/.env.example b/.env.example index 5417389..a044b1d 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,9 @@ REQUIRE_API_KEY=true # --- 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... # URL of the x402 facilitator (default: https://facilitator.stellar.org) X402_FACILITATOR_URL=https://facilitator.stellar.org diff --git a/README.md b/README.md index 7cd291f..9b048c7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,18 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | GET | `/pairs` | Watched trading pairs | | GET | `/status` | Indexer health | +Every route accepts an optional `?network=testnet\|mainnet` query param (or +`x-network` header) to pick the Stellar network — default is `testnet`. An +unrecognised value gets `400`. The `/price/*` endpoints' live SDEX pricing and +x402 payment `network`/`payTo` are fully per-request today; DB-backed reads +(candles, history, pools, AMM pricing) are still served from whichever +network this instance is currently indexing (`STELLAR_NETWORK`) — that data +layer isn't network-partitioned yet. + +```bash +curl "https://api.example.com/price/XLM/USDC?network=mainnet" +``` + ### GraphQL Available at `/graphql` with GraphiQL IDE at `/graphiql`. @@ -211,13 +223,14 @@ npm run dev | `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` (for x402 logic) | `testnet` | 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.* diff --git a/src/__tests__/bestRoute.test.ts b/src/__tests__/bestRoute.test.ts index e1a793f..5fdcff2 100644 --- a/src/__tests__/bestRoute.test.ts +++ b/src/__tests__/bestRoute.test.ts @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach } from 'vitest' -import { getBestRoute } from '../aggregator/bestRoute' +import { getBestRoute, _resetHorizonServers } from '../aggregator/bestRoute' import { pgPool } from '../db' import * as StellarSdk from '@stellar/stellar-sdk' @@ -25,6 +25,13 @@ vi.mock('@stellar/stellar-sdk', () => { vi.fn(function(code, issuer) { return { code, issuer } }), { native: vi.fn(() => 'native') } ), + // config.ts's buildNetworkConfig() falls back to these when no + // NETWORK_PASSPHRASE_* env var is set — needed now that getBestRoute + // resolves a per-network Horizon client via getNetworkConfig(). + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, __mockCall: callFn } }) @@ -39,6 +46,9 @@ describe('getBestRoute', () => { beforeEach(() => { vi.clearAllMocks() + // horizonServers is memoised at module scope (see bestRoute.ts) — clear + // between tests so each one observes fresh Horizon.Server() constructions. + _resetHorizonServers() }) it('Case 1: returns SDEX when SDEX price is better', async () => { @@ -107,7 +117,7 @@ describe('getBestRoute', () => { mockCall.mockResolvedValue({ records: [{ destination_amount: '123.456789' }] // 123.456789 / 1000 = 0.123456789 }) - + // AMM: no pool data to simplify test or give known value mockQuery.mockResolvedValue({ rows: [] } as any) @@ -115,4 +125,30 @@ describe('getBestRoute', () => { expect(result.sdexPrice).toBeCloseTo(0.123457, 6) }) + + it('Case 6: queries the mainnet Horizon server when network="mainnet"', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [] } as any) + + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + + const HorizonServerCtor = (StellarSdk as any).Horizon.Server + const urls = HorizonServerCtor.mock.calls.map((call: unknown[]) => call[0]) + expect(urls.some((url: string) => url.includes('horizon.stellar.org'))).toBe(true) + expect(urls.some((url: string) => url.includes('testnet'))).toBe(false) + }) + + it('Case 7: testnet and mainnet reuse a memoised Horizon server per network', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [] } as any) + + const HorizonServerCtor = (StellarSdk as any).Horizon.Server + const callsBefore = HorizonServerCtor.mock.calls.length + + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + + // Second mainnet call reuses the cached client — only one new Server() call. + expect(HorizonServerCtor.mock.calls.length).toBe(callsBefore + 1) + }) }) diff --git a/src/__tests__/middleware/network.test.ts b/src/__tests__/middleware/network.test.ts new file mode 100644 index 0000000..46df83c --- /dev/null +++ b/src/__tests__/middleware/network.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import Fastify from 'fastify' +import { registerNetworkSelector, resolveNetworkName } from '../../middleware/network' + +describe('resolveNetworkName', () => { + it('defaults to activeNetwork (testnet) when absent', () => { + expect(resolveNetworkName(undefined)).toEqual({ ok: true, network: 'testnet' }) + expect(resolveNetworkName(null)).toEqual({ ok: true, network: 'testnet' }) + expect(resolveNetworkName('')).toEqual({ ok: true, network: 'testnet' }) + }) + + it('accepts "testnet" and "mainnet", case-insensitively', () => { + expect(resolveNetworkName('mainnet')).toEqual({ ok: true, network: 'mainnet' }) + expect(resolveNetworkName('MAINNET')).toEqual({ ok: true, network: 'mainnet' }) + expect(resolveNetworkName(' testnet ')).toEqual({ ok: true, network: 'testnet' }) + }) + + it('rejects an unrecognised value', () => { + const result = resolveNetworkName('pubnet') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toMatch(/Invalid network "pubnet"/) + }) +}) + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + app.get('/echo', async (req) => ({ network: req.network })) + await app.ready() + return app +} + +describe('registerNetworkSelector', () => { + it('defaults req.network to testnet when no network is specified', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'testnet' }) + }) + + it('resolves req.network from the ?network= query param', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo?network=mainnet' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'mainnet' }) + }) + + it('resolves req.network from the x-network header', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo', headers: { 'x-network': 'mainnet' } }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'mainnet' }) + }) + + it('rejects an invalid network with 400', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo?network=pubnet' }) + expect(res.statusCode).toBe(400) + expect(res.json()).toHaveProperty('error') + }) +}) diff --git a/src/__tests__/middleware/x402.test.ts b/src/__tests__/middleware/x402.test.ts index 892a3a3..ab966cd 100644 --- a/src/__tests__/middleware/x402.test.ts +++ b/src/__tests__/middleware/x402.test.ts @@ -47,6 +47,8 @@ vi.mock('@x402/stellar/exact/server', () => ({ import Fastify from 'fastify' import { registerX402 } from '../../middleware/x402' +import { registerNetworkSelector } from '../../middleware/network' +import { _resetX402ResourceServers } from '../../x402/network' // ── Helpers ─────────────────────────────────────────────────────────────────── async function buildApp() { @@ -63,6 +65,18 @@ async function buildApp() { return app } +// Same as buildApp(), but with the network selector registered ahead of x402 +// so req.network is actually resolved from ?network=/x-network per request. +async function buildAppWithNetworkSelector() { + process.env.ORACLE_PAYMENT_ADDRESS = PAYMENT_ADDRESS + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await app.register(registerX402) + app.get('/price/test', async () => ({ ok: true })) + await app.ready() + return app +} + function makePaymentHeader(overrides: Record = {}): string { const payload = { scheme: 'exact', amount: '$0.10', recipient: PAYMENT_ADDRESS, ...overrides } return Buffer.from(JSON.stringify(payload)).toString('base64') @@ -73,6 +87,12 @@ beforeEach(() => { mockSettle.mockReset().mockResolvedValue(undefined) mockInitialize.mockReset().mockResolvedValue(undefined) mockRegisterChain.register.mockReturnValue(mockRegisterChain) + // Per-network resource servers are memoised at module scope (see + // x402/network.ts) — clear between tests so each one builds fresh against + // whatever ORACLE_PAYMENT_ADDRESS_* env vars it sets up. + _resetX402ResourceServers() + delete process.env.ORACLE_PAYMENT_ADDRESS_MAINNET + delete process.env.ORACLE_PAYMENT_ADDRESS_TESTNET }) // ── Tests ───────────────────────────────────────────────────────────────────── @@ -214,3 +234,60 @@ describe('x402 middleware', () => { expect(mockVerify).not.toHaveBeenCalled() }) }) + +describe('x402 middleware — per-request network', () => { + it('defaults to testnet requirements when no network is requested', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:testnet', payTo: PAYMENT_ADDRESS }) + }) + + it('resolves mainnet network/payTo from ?network=mainnet', async () => { + const MAINNET_ADDRESS = 'GMAINNETADDRESS123456789012345678901234567890123456789012' + process.env.ORACLE_PAYMENT_ADDRESS_MAINNET = MAINNET_ADDRESS + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=mainnet' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:pubnet', payTo: MAINNET_ADDRESS }) + }) + + it('falls back to the shared ORACLE_PAYMENT_ADDRESS when no mainnet-specific address is set', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=mainnet' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:pubnet', payTo: PAYMENT_ADDRESS }) + }) + + it('rejects an invalid ?network= before x402 even runs', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=pubnet' }) + + expect(res.statusCode).toBe(400) + expect(mockVerify).not.toHaveBeenCalled() + }) + + it('verifies a mainnet payment against mainnet requirements', async () => { + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ + method: 'GET', + url: '/price/test?network=mainnet', + headers: { 'x-payment': makePaymentHeader() }, + }) + + expect(res.statusCode).toBe(200) + expect(mockVerify).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ network: 'stellar:pubnet' }) + ) + }) +}) diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts index 177ab3e..bf3c408 100644 --- a/src/__tests__/price.test.ts +++ b/src/__tests__/price.test.ts @@ -20,17 +20,25 @@ vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +const { testnetPairs } = vi.hoisted(() => ({ + testnetPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } - }, - ], + pairs: testnetPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? testnetPairs : [], + }), })) import { registerRESTRoutes } from '../api/rest' diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 35fe76b..12ec5b4 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -31,17 +31,25 @@ vi.mock('../pricing/depth', () => ({ getDepth: mockGetDepth, })) +const { schemaTestPairs } = vi.hoisted(() => ({ + schemaTestPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - }, - ], + pairs: schemaTestPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? schemaTestPairs : [], + }), })) import { registerRESTRoutes } from '../api/rest' diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index 5b11639..7e5fa44 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -1,9 +1,25 @@ import { Horizon, Asset } from '@stellar/stellar-sdk' -import { config } from '../config' +import { activeNetwork, getNetworkConfig, type NetworkName } from '../config' import type { AssetId, RouteInfo } from '../types' import { pgPool } from '../db' -const horizonServer = new Horizon.Server(config.horizon.url) +// One Horizon client per network, built lazily so a network that's never +// requested never pays connection setup cost. +const horizonServers = new Map() + +function horizonServerFor(network: NetworkName): Horizon.Server { + let server = horizonServers.get(network) + if (!server) { + server = new Horizon.Server(getNetworkConfig(network).horizon.url) + horizonServers.set(network, server) + } + return server +} + +/** Test-only: clears the memoised per-network Horizon clients between test cases. */ +export function _resetHorizonServers(): void { + horizonServers.clear() +} function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() @@ -36,11 +52,11 @@ async function getAMMPrice(pairKey: string, amount: number): Promise { return output / amount // price per unit } -async function getSDEXPrice(assetA: AssetId, assetB: AssetId, amount: number): Promise { +async function getSDEXPrice(assetA: AssetId, assetB: AssetId, amount: number, network: NetworkName): Promise { try { const stellarAssetA = assetIdToStellar(assetA) const stellarAssetB = assetIdToStellar(assetB) - const paths = await horizonServer + const paths = await horizonServerFor(network) .strictSendPaths(stellarAssetA, amount.toString(), [stellarAssetB]) .call() if (paths.records.length === 0) return 0 @@ -55,10 +71,14 @@ export async function getBestRoute( assetA: AssetId, assetB: AssetId, pairKey: string, - amount: number = 1000 + amount: number = 1000, + // AMM pricing (below) reads price_points/pool_snapshots, which have no + // network column yet — that's the deeper aggregation-layer work. SDEX + // pricing is a live Horizon call, so it's genuinely per-network today. + network: NetworkName = activeNetwork ): Promise { const [sdexPrice, ammPrice] = await Promise.all([ - getSDEXPrice(assetA, assetB, amount), + getSDEXPrice(assetA, assetB, amount, network), getAMMPrice(pairKey, amount), ]) diff --git a/src/api/rest.ts b/src/api/rest.ts index 8bb8fff..b782e66 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -4,7 +4,8 @@ import { getCachedPrice, setCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' -import { config } from '../config' +import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' +import '../middleware/network' // declares req.network on the FastifyRequest type import { statusResponseSchema, priceResponseSchema, @@ -20,11 +21,11 @@ function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') } -function findPair(assetA: string, assetB: string) { +function findPair(assetA: string, assetB: string, network: NetworkName) { const normalize = (a: string) => a.toLowerCase() === 'native' ? 'XLM' : a.split(':')[0].toUpperCase() const cA = normalize(assetA) const cB = normalize(assetB) - return config.pairs.find(p => { + return getNetworkConfig(network).pairs.find(p => { const pA = p.assetA.code.toUpperCase() const pB = p.assetB.code.toUpperCase() return (cA === pA && cB === pB) || (cA === pB && cB === pA) @@ -58,10 +59,14 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { price_requests_total.inc() const { assetA, assetB } = req.params - const pair = findPair(assetA, assetB) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) - - const cached = await getCachedPrice(pair.pairKey) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + + // Cache key is network-scoped so testnet/mainnet prices for the same + // asset codes never collide. + const cacheKey = `${network}:${pair.pairKey}` + const cached = await getCachedPrice(cacheKey) if (cached) { try { reply.header('X-Cache', 'HIT') @@ -69,18 +74,22 @@ export async function registerRESTRoutes(app: FastifyInstance) { } catch { /* fall through */ } } + // NOTE: getAggregatedPrice reads price_points/price_aggregates, which + // have no network column yet — see getBestRoute's network param for + // the (currently SDEX-only) live per-network read. const agg = await getAggregatedPrice(pair.pairKey) - const route = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000) + const route = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000, network) const result = { assetA: pair.assetA.code, assetB: pair.assetB.code, pairKey: pair.pairKey, + network, ...agg, bestRoute: route.route, lastUpdated: new Date().toISOString(), } - await setCachedPrice(pair.pairKey, result, config.cache.priceTtl) + await setCachedPrice(cacheKey, result, config.cache.priceTtl) reply.header('X-Cache', 'MISS') return result } @@ -96,11 +105,12 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { const { assetA, assetB } = req.params const amount = parseFloat(req.query.amount ?? '1000') - const pair = findPair(assetA, assetB) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) - return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount) + return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network) } ) @@ -172,11 +182,13 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { const { assetA, assetB } = req.params const amount = parseFloat(req.query.amount ?? '1000') - const pair = findPair(assetA, assetB) - - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + // NOTE: getDepth reads order-book data with no network column yet — see L048. const depthResult = await getDepth(pair.pairKey, amount) return { diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 5784f06..4697d30 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -39,6 +39,7 @@ export const priceResponseSchema = { 'assetA', 'assetB', 'pairKey', + 'network', 'price', 'sdexPrice', 'ammPrice', @@ -62,6 +63,7 @@ export const priceResponseSchema = { assetA: { type: 'string' }, assetB: { type: 'string' }, pairKey: { type: 'string' }, + network: { type: 'string', enum: ['testnet', 'mainnet'] }, price: { type: 'number' }, sdexPrice: { type: 'number' }, ammPrice: { type: 'number' }, diff --git a/src/api/websocket.ts b/src/api/websocket.ts index 23ea79c..d570306 100644 --- a/src/api/websocket.ts +++ b/src/api/websocket.ts @@ -1,48 +1,56 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' import websocket from '@fastify/websocket' import { priceEmitter, PRICE_UPDATE, PriceUpdateEvent } from '../events' -// @ts-ignore -import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' -// @ts-ignore -import { ExactStellarScheme } from '@x402/stellar/exact/server' +import { activeNetwork, type NetworkName } from '../config' +import { X402_NETWORK_LABEL, paymentAddressFor, getX402ResourceServer } from '../x402/network' +import { resolveNetworkName } from '../middleware/network' import { fanOutManager } from '../ws/fanout' import { v4 as uuid } from 'uuid' -const PAYMENT_ADDRESS = process.env.ORACLE_PAYMENT_ADDRESS const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://facilitator.stellar.org' -const NETWORK = (process.env.STELLAR_NETWORK === 'mainnet' ? 'stellar:pubnet' : 'stellar:testnet') as string export async function registerWebSocket(app: FastifyInstance) { await app.register(websocket) - let resourceServer: any = null - if (PAYMENT_ADDRESS) { - try { - const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) - resourceServer = new x402ResourceServer(facilitatorClient) - .register(NETWORK as `${string}:${string}`, new ExactStellarScheme()) - await resourceServer.initialize() - } catch (err) { - app.log.warn(`[ws] x402 init failed, streaming without payment gating: ${(err as Error).message}`) - resourceServer = null - } - } - // @fastify/websocket v11: handler receives (socket, req) directly — no connection wrapper app.get('/ws', { websocket: true, config: { public: true } }, (socket: any, req: FastifyRequest) => { app.log.info('[ws] New connection attempt') + const rawNetwork = (req.query as any)?.network ?? (req.headers['x-network'] as string | undefined) + const resolved = resolveNetworkName(rawNetwork) + if (!resolved.ok) { + socket.send(JSON.stringify({ type: 'error', status: 400, message: resolved.error })) + socket.close() + return + } + const network: NetworkName = resolved.network + + // This process only ingests and streams live prices for `activeNetwork` + // (see src/config.ts) — the underlying price events carry no network tag + // yet (that's the deeper aggregation-layer work), so a request for any + // other network can't be honestly served here. + if (network !== activeNetwork) { + socket.send(JSON.stringify({ + type: 'error', + status: 400, + message: `This instance streams "${activeNetwork}" only; requested "${network}"`, + })) + socket.close() + return + } + + const paymentAddress = paymentAddressFor(network) const paymentHeader = (req.headers['x-payment'] as string) || (req.query as any).payment const requirements = { scheme: 'exact' as const, price: '$0.50', - network: NETWORK, - payTo: PAYMENT_ADDRESS!, + network: X402_NETWORK_LABEL[network], + payTo: paymentAddress!, } - if (!PAYMENT_ADDRESS || !resourceServer) { - app.log.warn('[ws] x402 disabled (PAYMENT_ADDRESS missing or x402 init failed)') + if (!paymentAddress) { + app.log.warn('[ws] x402 disabled (no payment address configured for this network)') } else if (!paymentHeader) { socket.send(JSON.stringify({ type: 'error', @@ -53,7 +61,8 @@ export async function registerWebSocket(app: FastifyInstance) { socket.close() return } else { - verifyPayment(paymentHeader, requirements, resourceServer) + getX402ResourceServer(network, FACILITATOR_URL) + .then(resourceServer => verifyPayment(paymentHeader, requirements, resourceServer)) .then(isValid => { if (!isValid) { socket.send(JSON.stringify({ type: 'error', message: 'Invalid payment' })) diff --git a/src/index.ts b/src/index.ts index 1d12cb8..60f55cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { registerPairsRoutes } from './routes/pairs' import { registerScreenerRoutes } from './routes/screener' import { registerHistoryRoutes } from './api/history' import { registerX402 } from './middleware/x402' +import { registerNetworkSelector } from './middleware/network' import { registerWebSocket } from './api/websocket' import { registerApiKeyAuth } from './api/auth' import { registerAdminRoutes } from './api/admin' @@ -61,6 +62,12 @@ async function main() { await app.register(cors, { origin: true }) await app.register(compress) + // Resolves the per-request Stellar network (?network= query param / x-network + // header) onto req.network, validating it (400 on an unrecognised value). + // Runs in onRequest, ahead of API-key auth/rate-limiting/x402 and every route + // handler, so all of them can read req.network. + await app.register(registerNetworkSelector) + // API-key authentication — validates Authorization: Bearer and attaches // per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so // that req.apiKey is populated when the limiter evaluates its per-key quota diff --git a/src/middleware/network.ts b/src/middleware/network.ts new file mode 100644 index 0000000..8519559 --- /dev/null +++ b/src/middleware/network.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import fp from 'fastify-plugin' +import { activeNetwork, type NetworkName } from '../config' + +const VALID_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] + +declare module 'fastify' { + interface FastifyRequest { + /** + * The Stellar network this request targets, resolved from the `network` + * query param / `x-network` header (see {@link resolveNetworkName}). + * Defaults to `activeNetwork` when the request specifies nothing. + */ + network: NetworkName + } +} + +/** + * Resolves a raw `network` value (query param or header) into a validated + * {@link NetworkName}. An absent/empty value resolves to `activeNetwork` + * (this deployment's configured default) rather than being an error — only + * an explicit, unrecognised value is rejected. + */ +export function resolveNetworkName( + raw: string | undefined | null +): { ok: true; network: NetworkName } | { ok: false; error: string } { + if (raw == null || raw === '') return { ok: true, network: activeNetwork } + const lower = raw.trim().toLowerCase() + if ((VALID_NETWORKS as string[]).includes(lower)) { + return { ok: true, network: lower as NetworkName } + } + return { + ok: false, + error: `Invalid network "${raw}" — expected one of: ${VALID_NETWORKS.join(', ')}`, + } +} + +function rawNetworkFromRequest(req: FastifyRequest): string | undefined { + const fromQuery = (req.query as Record | undefined)?.network + if (typeof fromQuery === 'string') return fromQuery + + const fromHeader = req.headers['x-network'] + if (typeof fromHeader === 'string') return fromHeader + + return undefined +} + +/** + * Fastify plugin that resolves the per-request Stellar network from a + * `?network=` query param or `x-network` header, validates it, and attaches + * it to `req.network`. An unrecognised value gets a 400 before any route + * handler or downstream middleware (x402, WebSocket auth) runs. + * + * Must be registered early (`onRequest`) so `req.network` is populated + * before `middleware/x402.ts`'s `preHandler` hook and any route handler. + */ +async function networkSelectorPlugin(app: FastifyInstance) { + app.decorateRequest('network', activeNetwork) + + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + const resolved = resolveNetworkName(rawNetworkFromRequest(req)) + if (!resolved.ok) { + reply.status(400).send({ error: resolved.error }) + return + } + req.network = resolved.network + }) +} + +export const registerNetworkSelector = fp(networkSelectorPlugin, { name: 'network-selector' }) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index e16ce89..bc1da10 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -1,11 +1,9 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { x402_payments_received_total } from '../metrics' import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/metering' +import { X402_NETWORK_LABEL, paymentAddressFor, isX402Configured, getX402ResourceServer } from '../x402/network' import fp from 'fastify-plugin' -// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution -import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' -// @ts-ignore -import { ExactStellarScheme } from '@x402/stellar/exact/server' +import './network' // declares req.network on the FastifyRequest type // Routes gated by x402 and their prices const GATED_ROUTES: Record = { @@ -21,20 +19,13 @@ const GATED_ROUTES: Record = { */ async function x402Plugin(app: FastifyInstance) { // Read at plugin init time (not module load) so tests can inject env vars before app.register() - const PAYMENT_ADDRESS = process.env.ORACLE_PAYMENT_ADDRESS const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://facilitator.stellar.org' - const NETWORK = (process.env.STELLAR_NETWORK === 'mainnet' ? 'stellar:pubnet' : 'stellar:testnet') as string - if (!PAYMENT_ADDRESS) { + if (!isX402Configured()) { app.log.warn('[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled') return } - const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) - const resourceServer: any = new x402ResourceServer(facilitatorClient) - .register(NETWORK as `${string}:${string}`, new ExactStellarScheme()) - - await resourceServer.initialize() app.log.info('[oracle] x402 payment gating enabled') app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => { @@ -46,14 +37,23 @@ async function x402Plugin(app: FastifyInstance) { }) if (!matchedRoute) return + // Falls back to testnet when the network selector plugin isn't + // registered (e.g. isolated unit tests that build the app directly). + const network = req.network ?? 'testnet' + const paymentAddress = paymentAddressFor(network) + if (!paymentAddress) { + reply.status(402).send({ error: `x402 payments are not configured for network "${network}"` }) + return + } + const { price, description } = GATED_ROUTES[matchedRoute] const paymentHeader = req.headers['x-payment'] as string | undefined const requirements = { scheme: 'exact' as const, price, - network: NETWORK, - payTo: PAYMENT_ADDRESS, + network: X402_NETWORK_LABEL[network], + payTo: paymentAddress, } // No payment header — return 402 with requirements @@ -76,6 +76,7 @@ async function x402Plugin(app: FastifyInstance) { payload = JSON.parse(paymentHeader) } + const resourceServer = await getX402ResourceServer(network, FACILITATOR_URL) const result = await resourceServer.verify(payload, requirements) if (!result.isValid) { reply.status(402).send({ error: 'Payment invalid', reason: result.invalidReason }) diff --git a/src/x402/network.ts b/src/x402/network.ts new file mode 100644 index 0000000..b2e9aed --- /dev/null +++ b/src/x402/network.ts @@ -0,0 +1,53 @@ +import type { NetworkName } from '../config' +// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution +import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' +// @ts-ignore +import { ExactStellarScheme } from '@x402/stellar/exact/server' + +/** x402 chain identifier per Stellar network. */ +export const X402_NETWORK_LABEL: Record = { + testnet: 'stellar:testnet', + mainnet: 'stellar:pubnet', +} + +/** + * The x402 payment address for a given network. + * + * Resolution order (first non-empty wins), mirroring `config.ts`'s per-network + * env var convention: + * 1. `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` + * 2. `ORACLE_PAYMENT_ADDRESS` (back-compat with single-network setups) + */ +export function paymentAddressFor(network: NetworkName): string | undefined { + const suffix = network.toUpperCase() + return process.env[`ORACLE_PAYMENT_ADDRESS_${suffix}`] || process.env.ORACLE_PAYMENT_ADDRESS +} + +/** True if x402 gating should be active for at least one network. */ +export function isX402Configured(): boolean { + return Boolean(paymentAddressFor('testnet') || paymentAddressFor('mainnet')) +} + +// One resource server per network, built and initialised lazily on first use +// so a network that's never requested never pays the initialize() cost. +const resourceServers = new Map>() + +export function getX402ResourceServer(network: NetworkName, facilitatorUrl: string): Promise { + let pending = resourceServers.get(network) + if (!pending) { + pending = (async () => { + const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }) + const server: any = new x402ResourceServer(facilitatorClient) + .register(X402_NETWORK_LABEL[network], new ExactStellarScheme()) + await server.initialize() + return server + })() + resourceServers.set(network, pending) + } + return pending +} + +/** Test-only: clears the memoised resource servers between test cases. */ +export function _resetX402ResourceServers(): void { + resourceServers.clear() +} diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts index a842e61..0f35d0f 100644 --- a/tests/aggregator.property.test.ts +++ b/tests/aggregator.property.test.ts @@ -27,6 +27,13 @@ vi.mock('@stellar/stellar-sdk', () => { }), { native: vi.fn(() => 'native') } ), + // config.ts's buildNetworkConfig() falls back to these when no + // NETWORK_PASSPHRASE_* env var is set — needed now that getBestRoute + // resolves a per-network Horizon client via getNetworkConfig(). + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, __mockCall: callFn, } }) @@ -42,7 +49,11 @@ describe('Price aggregator property tests', () => { vi.clearAllMocks() }) - it('produces valid route results for random venue prices', async () => { + // 10,000 fast-check runs of getBestRoute now actually execute (previously + // this test failed before running a single iteration — the mocked + // @stellar/stellar-sdk had no Networks export, which getNetworkConfig() + // needs); that volume of real work needs more than the 5s default. + it('produces valid route results for random venue prices', { timeout: 30000 }, async () => { await fc.assert( fc.asyncProperty( fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }), diff --git a/tests/staleness.test.ts b/tests/staleness.test.ts index af2d6ba..2b54f0b 100644 --- a/tests/staleness.test.ts +++ b/tests/staleness.test.ts @@ -20,17 +20,25 @@ vi.mock('../src/aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +const { stalenessTestPairs } = vi.hoisted(() => ({ + stalenessTestPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../src/config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - }, - ], + pairs: stalenessTestPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? stalenessTestPairs : [], + }), })) import { registerRESTRoutes } from '../src/api/rest'