From 7e2716a0abe1cad74656b708821735e2b966340b Mon Sep 17 00:00:00 2001 From: Vincent ibochi <290086463+ibochivincent-lang@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:57:06 +0100 Subject: [PATCH 01/12] feat(api): add network selector on routes + per-request x402 network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a network-selector Fastify plugin (src/middleware/network.ts) that resolves a per-request Stellar network from a ?network= query param or x-network header, validates it (400 on an unrecognised value, default testnet), and attaches it to req.network — registered early so every route, x402, and the WebSocket handler can read it. x402 (both REST and /ws) now resolves its network label and payTo per request from req.network instead of a fixed value computed once from STELLAR_NETWORK at plugin init, via new shared helpers in src/x402/network.ts. Supports optional per-network payment addresses (ORACLE_PAYMENT_ADDRESS_TESTNET/_MAINNET, falling back to the existing ORACLE_PAYMENT_ADDRESS). /price/:assetA/:assetB and /price/:assetA/:assetB/route now look up watched pairs and live SDEX pricing (a real Horizon call) per the resolved network, so ?network=mainnet genuinely returns mainnet SDEX pricing; the price-cache key is network-scoped so testnet/mainnet don't collide. DB-backed reads (candles, history, pools, AMM pricing, GraphQL resolvers) have no network column yet and still serve from this instance's configured STELLAR_NETWORK — documented in the README as follow-up work. /ws validates its requested network the same way and rejects (400) a network other than the one this instance actually streams, since price events carry no network tag yet either. Also fixes a pre-existing bug in three test files' @stellar/stellar-sdk mocks (missing Networks export), which silently broke tests/aggregator.property.test.ts and made src/__tests__/bestRoute.test.ts report false results — both surfaced by getBestRoute's new getNetworkConfig() call. --- .env.example | 3 + README.md | 15 ++++- src/__tests__/bestRoute.test.ts | 40 +++++++++++- src/__tests__/middleware/network.test.ts | 61 +++++++++++++++++++ src/__tests__/middleware/x402.test.ts | 77 ++++++++++++++++++++++++ src/__tests__/price.test.ts | 22 ++++--- src/__tests__/schemaValidation.test.ts | 22 ++++--- src/aggregator/bestRoute.ts | 32 ++++++++-- src/api/rest.ts | 42 ++++++++----- src/api/schemas.ts | 2 + src/api/websocket.ts | 57 ++++++++++-------- src/index.ts | 7 +++ src/middleware/network.ts | 70 +++++++++++++++++++++ src/middleware/x402.ts | 29 ++++----- src/x402/network.ts | 53 ++++++++++++++++ tests/aggregator.property.test.ts | 13 +++- tests/staleness.test.ts | 22 ++++--- 17 files changed, 483 insertions(+), 84 deletions(-) create mode 100644 src/__tests__/middleware/network.test.ts create mode 100644 src/middleware/network.ts create mode 100644 src/x402/network.ts diff --git a/.env.example b/.env.example index 54173892..a044b1d8 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 7cd291f1..9b048c7d 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 e1a793f0..5fdcff2e 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 00000000..46df83c7 --- /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 892a3a30..ab966cd8 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 177ab3e7..bf3c408c 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 35fe76ba..12ec5b4b 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 5b116397..7e5fa448 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 8bb8fff4..b782e666 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 5784f06b..4697d30b 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 23ea79cf..d5703067 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 1d12cb85..60f55cf8 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 00000000..8519559f --- /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 e16ce898..bc1da102 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 00000000..b2e9aede --- /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 a842e619..0f35d0f3 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 af2d6ba6..2b54f0b2 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' From 68a3ab2bb8cedfc73ba45a4a9cd712546261cb0b Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sat, 29 Aug 2026 11:34:18 +0100 Subject: [PATCH 02/12] docs: draft upto stellar scheme summary and contract decision --- docs/x402/scheme_upto_stellar.md | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/x402/scheme_upto_stellar.md diff --git a/docs/x402/scheme_upto_stellar.md b/docs/x402/scheme_upto_stellar.md new file mode 100644 index 00000000..d2cc1885 --- /dev/null +++ b/docs/x402/scheme_upto_stellar.md @@ -0,0 +1,56 @@ +# Scheme: `upto` on `Stellar` + +> Draft specification for the x402 `upto` scheme on Stellar. Stellar currently +> has a specified `exact` scheme upstream but no `upto` scheme; this document +> proposes one, matching the format of the existing `exact` Stellar spec and +> the `upto` specs for EVM and SVM in the +> [x402 protocol repository](https://github.com/x402-foundation/x402). + +## Versions supported + +- ❌ `v1` - we don't plan to support v1 for now. +- ✅ `v2` + +## Supported Networks + +This spec uses [CAIP-2](https://namespaces.chainagnostic.org/stellar/caip2) identifiers: +- `stellar:pubnet` — Stellar mainnet +- `stellar:testnet` — Stellar testnet + +## Summary + +The x402 `upto` scheme on Stellar authorizes a transfer of up to a **maximum amount**, with the actual amount settled after resource consumption is known. As with [`exact` on Stellar][scheme-exact-stellar], the client authorizes via a signed Soroban authorization entry rather than a full transaction signature, and the facilitator sponsors transaction fees. + +> [!NOTE] +> **Scope:** This spec covers [SEP-41]-compliant Soroban tokens **only**. Classic Stellar assets are not supported, matching [`exact` on Stellar][scheme-exact-stellar]. + +## Contract-vs-No-Contract Decision + +**This spec ships a Soroban contract: `x402UptoStellar`.** + +A bare [SEP-41] `approve` / `transfer_from` allowance cannot, on its own, provide the three guarantees the [`upto` core spec][scheme-upto] requires of every network implementation: + +- **Recipient binding** — a SEP-41 allowance authorizes a `spender` to move up to `amount`, to *any* address that spender chooses. It does not bind the transfer to a specific `payTo`. +- **Single-use authorization** — a SEP-41 allowance is a standing balance the spender can draw down across many calls; it has no built-in single-settlement or replay-protection semantics. +- **Time-bound authorization with an explicit `validAfter`/deadline pair** — SEP-41's `approve` supports an expiration ledger, but not a `validAfter` start bound, and nothing stops the spender from settling more than once before expiry. + +This mirrors the precedent already set by the two implemented networks: EVM does not rely on a bare ERC-20 `approve` either — it ships the purpose-built `x402UptoPermit2Proxy` contract, which wraps Permit2's witness pattern to add recipient binding, a nonce, and settle-time cap enforcement. SVM ships the `payment-channels` program for the same reason. A Stellar design that stopped at "the client approves the facilitator" would be strictly weaker than both existing implementations, not merely different from them — so this spec does not offer a contract-free path. + +The `x402UptoStellar` contract is intentionally thin: it does not escrow the client's funds up front (unlike SVM's channel model). Instead, it composes with a standard SEP-41 `approve`, and adds the missing recipient binding, nonce, and cap enforcement as a signed-authorization wrapper around `transfer_from`, closer in spirit to the EVM Permit2 witness pattern than to SVM's escrow. This keeps capital efficient (no funds are locked before settlement) and keeps the per-request signing step off-chain and free, consistent with how `exact` on Stellar already uses auth-entry signing instead of on-chain transactions for the client's half of the flow. + +## Summary of the Flow + +1. **Client** performs a one-time (or periodic) [SEP-41] `approve(spender: , amount: , expiration_ledger)` on the token contract. This is an on-chain transaction, analogous to EVM's Phase 1 Permit2 approval — it is not part of the per-request payment flow and is skipped on subsequent requests while the allowance remains sufficient and unexpired. +2. **Resource Server** responds `402 Payment Required` with `PaymentRequirements` for `upto`, including `extra.uptoContract` (the `x402UptoStellar` contract address) and `extra.facilitatorAddress`. +3. **Client** signs a Soroban authorization entry for `x402UptoStellar.settle_upto(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`, with `max_amount` set to the authorized ceiling for this request (this is the `PaymentRequirements.amount` field at verification time), `to` set to `requirements.payTo`, and `facilitator` set to `extra.facilitatorAddress`. Expiration is derived from `maxTimeoutSeconds` exactly as in `exact` on Stellar. +4. **Client** serializes the signed authorization entry (base64 XDR) and sends it to the resource server as the `PaymentPayload`. +5. **Resource Server** forwards the payload to the **Facilitator**'s `/verify` endpoint, which checks the authorization entry's structure, expiration, and that `requirements.amount <= max_amount`, without settling. +6. **Resource Server** executes the request, determines the actual cost, and calls the facilitator's `/settle` endpoint with `requirements.amount` set to the actual amount (phase-dependent `amount`, per the [`upto` core spec][scheme-upto] §5). +7. **Facilitator** re-verifies the client's authorization entry against `max_amount` (the signed ceiling, not the settlement-time amount — see [Settle-Time Verification](#settle-time-verification)), then calls `x402UptoStellar.settle_upto(auth_entry, actual_amount)`. +8. **Contract** checks the nonce has not been used, `now <= expiration_ledger`, `facilitator.require_auth()` matches the signed `facilitator`, and `actual_amount <= max_amount`; it then calls `token.transfer_from(spender: self, from, to, actual_amount)` and marks the nonce consumed. +9. **Facilitator** submits the transaction, sponsoring fees as in `exact`, and returns a `SettlementResponse` to the **Resource Server**, which grants access to the **Client**. + +[SEP-41]: https://stellar.org/protocol/sep-41 +[scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md +[scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md +[scheme-upto-evm-settle]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md#settle-time-verification From 0425aa49921bf78ae131149b57c9f28708970a4c Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sat, 29 Aug 2026 11:34:49 +0100 Subject: [PATCH 03/12] docs: add upto stellar payload schema and verification rules --- docs/x402/scheme_upto_stellar.md | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/x402/scheme_upto_stellar.md b/docs/x402/scheme_upto_stellar.md index d2cc1885..fd48aefc 100644 --- a/docs/x402/scheme_upto_stellar.md +++ b/docs/x402/scheme_upto_stellar.md @@ -50,6 +50,90 @@ The `x402UptoStellar` contract is intentionally thin: it does not escrow the cli 8. **Contract** checks the nonce has not been used, `now <= expiration_ledger`, `facilitator.require_auth()` matches the signed `facilitator`, and `actual_amount <= max_amount`; it then calls `token.transfer_from(spender: self, from, to, actual_amount)` and marks the nonce consumed. 9. **Facilitator** submits the transaction, sponsoring fees as in `exact`, and returns a `SettlementResponse` to the **Resource Server**, which grants access to the **Client**. +## `PaymentRequirements` for `upto` + +```json +{ + "scheme": "upto", + "network": "stellar:testnet", + "amount": "10000000", + "asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + "payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO", + "maxTimeoutSeconds": 300, + "extra": { + "areFeesSponsored": true, + "uptoContract": "CA...UPTOSTELLARCONTRACTADDRESS", + "facilitatorAddress": "GBFACILITATOR..." + } +} +``` + +**Field Definitions:** + +- `amount`: Phase-dependent per the [`upto` core spec][scheme-upto] — the authorized maximum at verification time, the actual settlement amount at settlement time. +- `extra.uptoContract`: The deployed `x402UptoStellar` contract address the client must authorize against. +- `extra.facilitatorAddress`: The facilitator the client binds into the signed authorization (mirrors EVM's `witness.facilitator`); prevents settlement by any other party. +- `extra.areFeesSponsored`: As in `exact` — currently always `true`. + +## PaymentPayload `payload` Field + +```json +{ + "authEntry": "AAAAAgAAAABriIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1AAAAAAAAAAAAAAAAAAAAA...", + "nonce": "3f1a...b2", + "maxAmount": "10000000" +} +``` + +- `authEntry`: Base64-encoded XDR of the signed Soroban authorization entry for `settle_upto`. +- `nonce`: The 32-byte nonce bound into the authorization entry, surfaced separately so the facilitator can perform a fast pre-check without decoding XDR. +- `maxAmount`: The signed ceiling, echoed outside the XDR for the same reason. + +## Facilitator Verification Rules (MUST) + +### 1. Protocol Validation + +Same as `exact` on Stellar: `x402Version` MUST be `2`, `scheme` MUST be `"upto"` on both sides, `network` MUST match. + +### 2. Authorization Entry Structure + +- The authorization entry MUST target the `x402UptoStellar` contract at `extra.uptoContract` and the `settle_upto` function. +- Arguments MUST be exactly `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`. +- `to` MUST equal `requirements.payTo` exactly. +- `asset` MUST equal `requirements.asset` exactly. +- `facilitator` MUST equal `extra.facilitatorAddress` exactly. +- Credential type MUST be `sorobanCredentialsAddress`, matching `exact`. + +### 3. Cap and Amount Rules + +- At **verify** time: `requirements.amount` (the authorized maximum) MUST equal the signed `max_amount`. +- At **settle** time: `requirements.amount` (the actual settlement amount) MUST be `<= max_amount` from the signed entry. The facilitator MUST re-verify the authorization entry's signature against `max_amount`, never against the settlement-time amount — see [Settle-Time Verification](#settle-time-verification). +- The settled amount MAY be `0`. + +### 4. Time Bounds and Replay + +- `expiration_ledger` MUST NOT exceed `currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds)` (fallback `5` seconds/ledger, as in `exact`). +- The facilitator MUST query the contract's nonce state (or simulate `settle_upto`) before submitting, to short-circuit already-consumed nonces. +- The contract itself is the source of truth for replay protection: `settle_upto` MUST fail if `nonce` has already been marked consumed. + +### 5. 🚨🚨🚨 Facilitator Safety + +Same as `exact` §4: the facilitator's own address MUST NOT be `from`, MUST NOT appear as an unexpected signer, and simulation MUST show only the expected balance change (`from` decrease of `actual_amount`, `to` increase of `actual_amount`) plus no other balance changes. + +### 6. Allowance Precondition + +- Before verification can succeed, the facilitator MUST confirm `token.allowance(from, uptoContract) >= max_amount` and unexpired. If insufficient, the facilitator MUST return `412 Precondition Failed` with an error code equivalent to EVM's `PERMIT2_ALLOWANCE_REQUIRED`, signaling the client to submit the one-time `approve` first. + +## Settle-Time Verification + +Identical rationale to [EVM `upto` §Settle-Time Verification][scheme-upto-evm-settle]: because `amount` is phase-dependent, the facilitator MUST: + +1. Verify the authorization entry's signature against the signed `max_amount` (the ceiling), not `requirements.amount` (the actual settlement amount) — the client signed for the ceiling, and comparing against the metered amount would reject every partial settlement. +2. Validate `requirements.amount <= max_amount`. +3. Call `settle_upto` with `actual_amount = requirements.amount`. + +A facilitator that instead enforces `requirements.amount === max_amount` at settle time will reject all partial settlements, breaking the core `upto` value proposition. + [SEP-41]: https://stellar.org/protocol/sep-41 [scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md [scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md From 33614b9a045edc0da1c24b8dc4245a4c8f292f1f Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sat, 29 Aug 2026 11:35:16 +0100 Subject: [PATCH 04/12] docs: add upto stellar contract, spending-policy composition and security notes --- docs/x402/scheme_upto_stellar.md | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/x402/scheme_upto_stellar.md b/docs/x402/scheme_upto_stellar.md index fd48aefc..b0641589 100644 --- a/docs/x402/scheme_upto_stellar.md +++ b/docs/x402/scheme_upto_stellar.md @@ -134,6 +134,51 @@ Identical rationale to [EVM `upto` §Settle-Time Verification][scheme-upto-evm-s A facilitator that instead enforces `requirements.amount === max_amount` at settle time will reject all partial settlements, breaking the core `upto` value proposition. +## `x402UptoStellar` Contract + +Reference behavior (Soroban, Rust): + +- `settle_upto(from: Address, to: Address, asset: Address, max_amount: i128, facilitator: Address, nonce: BytesN<32>, expiration_ledger: u32, actual_amount: i128)` + - Requires `from`'s authorization on this invocation with the fixed args `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)` — `actual_amount` is deliberately **not** part of the signed argument set, since it is only known at settle time; this is the Stellar analogue of Permit2's witness/permitted-amount split. + - Requires `facilitator.require_auth()`, binding settlement to the designated facilitator. + - Fails if `nonce` has already been consumed for `from`. + - Fails if `env.ledger().sequence() > expiration_ledger`. + - Fails if `actual_amount > max_amount` or `actual_amount < 0`. + - On success: calls `token_client.transfer_from(&env.current_contract_address(), &from, &to, &actual_amount)`, marks `nonce` consumed, and emits a `settle` event with `(from, to, asset, actual_amount, nonce)`. +- Zero settlement (`actual_amount == 0`): the facilitator MAY skip calling `settle_upto` entirely and let the authorization entry expire unused, exactly as EVM's zero-settlement case — no on-chain transaction, no gas cost, the nonce is simply never consumed. If the resource server needs an on-chain record of the zero-charge decision, the facilitator MAY still call `settle_upto` with `actual_amount = 0` to consume the nonce and close out the authorization explicitly. + +## Composition with Smart Account Spending Policies + +The `x402UptoStellar` contract enforces guarantees about a *single authorization*: it cannot be settled twice, cannot exceed its signed ceiling, and cannot be redirected to a different recipient. It says nothing about how many such authorizations a given signer is allowed to produce in total — that is a separate concern, and on Stellar it is naturally handled one layer up, at the smart account (C-account) level, rather than inside the x402 contract itself. + +For an agentic payer whose Stellar account is a smart wallet (e.g. a passkey-backed contract account exposing its own `approve(spender, token, amount, expiry)` policy independent of the underlying SEP-41 token allowance), the composition looks like this: + +- The **x402 `upto` authorization** (this spec) bounds a single request: at most `max_amount`, to exactly `payTo`, settleable at most once. +- The **smart account's own spending policy** bounds the agent's signing key across *all* requests: it governs whether the wallet will co-sign an `approve` or `settle_upto` authorization entry for a given `(spender, token)` pair at all, independent of what any individual x402 authorization says. + +These two layers are enforced at different points and do not need to agree on mechanism: the smart account's policy check happens client-side, when the wallet decides whether to produce a signature for the agent's requested authorization entry; the `x402UptoStellar` contract's checks happen on-chain, at settle time, regardless of what kind of account `from` is. An agent authorized up to a per-request cap by `upto`, running against a smart wallet with its own aggregate spending policy, is bounded by both: the smart wallet refuses to keep signing once its own policy limit is reached, even if individual `upto` ceilings would otherwise permit more spend. This is a recommended defense-in-depth pattern for agent use cases, not a protocol requirement — `upto` on Stellar functions identically for a plain G-account payer with no wallet-level policy at all. + +## Out of Scope + +- **`batch-settlement`**: settling more than once against the same authorization (streaming/pay-per-chunk) is explicitly out of scope for `upto`, per the [core spec][scheme-upto]. Nothing in `x402UptoStellar`'s nonce design forecloses a future `batch-settlement` scheme built on the same escrow/voucher primitives used elsewhere in the ecosystem — that would be a distinct scheme with its own spec. +- **`auth-capture`**: deferred; the single-settlement nonce model here does not preclude a future two-phase design. + +## Error Codes + +In addition to the standard x402 error codes: + +- **`invalid_upto_stellar_payload_settlement_exceeds_amount`**: attempted settlement amount exceeds the signed `max_amount`. +- **`invalid_upto_stellar_payload_nonce_consumed`**: the authorization entry's nonce has already been settled. +- **`invalid_upto_stellar_payload_allowance_required`**: the client has not approved `x402UptoStellar` for at least `max_amount` (see [Allowance Precondition](#6-allowance-precondition)). + +## Security Considerations + +1. **Maximum amount authorization**: as in EVM/SVM, clients should sign `max_amount` conservatively; the facilitator can settle for any amount up to it. +2. **Server trust**: `upto` requires trusting the resource server to report actual usage honestly; this is unchanged from the core scheme. +3. **Allowance ceiling vs per-request ceiling**: the SEP-41 `approve` ceiling and the per-request `max_amount` are independent. Implementations SHOULD keep the allowance close to the expected per-request ceiling and re-approve as needed, rather than approving a large standing balance, to limit exposure if a facilitator or the escrow contract is ever compromised. +4. **Nonce exhaustion / griefing**: because settlement is optional (zero-settlement need not touch the chain), a malicious client cannot force facilitator gas spend by signing many unused authorizations; only the facilitator's own `/settle` calls cost gas, and it only calls them for its own resource server's confirmed usage. +5. **Smart account composition**: see [Composition with Smart Account Spending Policies](#composition-with-smart-account-spending-policies) — this is an additive, optional safeguard and its absence does not weaken the guarantees this spec makes about a single authorization. + [SEP-41]: https://stellar.org/protocol/sep-41 [scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md [scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md From a9d915956eba8011a71d9aba1e467d2e979dcda0 Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Sat, 29 Aug 2026 19:59:11 +0100 Subject: [PATCH 05/12] feat: make Soroswap, Aquarius, and Reflector config per-network Move the Soroswap token-list URL, Aquarius pools API URL, and their enable flags into the per-network config block so dual-network deployments can point each venue at network-appropriate endpoints instead of the previously hardcoded mainnet-only URLs. Aquarius has no public testnet deployment today, so it now defaults to disabled on testnet and enabled on mainnet; both flags are overridable via AQUARIUS_ENABLED_TESTNET / AQUARIUS_ENABLED_MAINNET. Soroswap defaults to enabled on both networks and is likewise overridable via SOROSWAP_ENABLED_TESTNET / SOROSWAP_ENABLED_MAINNET. The Reflector oracle is now considered disabled whenever no contract id is configured for the active network, so callers can check config.oracle.enabled instead of special-casing an empty string. startSoroswapIngester and startAquariusIngester return immediately without entering their polling loop when their venue is disabled on the active network, so a network with no deployment does not spin on a venue that will only ever fail. --- .env.example | 13 +++ src/__tests__/aquariusIngester.test.ts | 82 ++++++++++++++++ src/__tests__/networkVenueConfig.test.ts | 116 +++++++++++++++++++++++ src/__tests__/soroswapEnabled.test.ts | 44 +++++++++ src/config.ts | 46 ++++++++- src/ingest/oracles/reflector.ts | 2 + src/ingest/venues/aquarius.ts | 14 ++- src/ingesters/soroswap.ts | 17 ++-- 8 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/aquariusIngester.test.ts create mode 100644 src/__tests__/networkVenueConfig.test.ts create mode 100644 src/__tests__/soroswapEnabled.test.ts diff --git a/.env.example b/.env.example index 54173892..4ec4427d 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org RPC_URL_TESTNET=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS_TESTNET=CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3 +# Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. +SOROSWAP_ENABLED_TESTNET=true +# Aquarius has no public testnet deployment today — disabled by default. +AQUARIUS_ENABLED_TESTNET=false REFLECTOR_CONTRACT_ID_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. @@ -53,11 +57,20 @@ RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 # Mainnet Soroswap factory contract address — see https://github.com/soroswap/core SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 +SOROSWAP_ENABLED_MAINNET=true +AQUARIUS_ENABLED_MAINNET=true # Reflector oracle contract on mainnet — see https://reflector.network REFLECTOR_CONTRACT_ID_MAINNET=CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# --- Venue endpoint overrides (optional, shared across networks unless a +# paired _TESTNET / _MAINNET variant is set) --- +# Soroswap token-list JSON URL. +SOROSWAP_TOKEN_LIST_URL=https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json +# Aquarius AMM pools API base URL. +AQUARIUS_API_URL=https://amm.aquarius.network/api/v1/pools/ + # --- Back-compat single-network vars (testnet) --- # These are still respected when the paired _TESTNET vars above are unset. # New deployments should prefer the paired vars above. diff --git a/src/__tests__/aquariusIngester.test.ts b/src/__tests__/aquariusIngester.test.ts new file mode 100644 index 00000000..e4ad4040 --- /dev/null +++ b/src/__tests__/aquariusIngester.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for the Aquarius AMM venue adapter. + */ + +const mocks = vi.hoisted(() => ({ + config: { + aquarius: { enabled: true, apiUrl: 'https://amm.aquarius.network/api/v1/pools/' }, + indexer: { pollIntervalMs: 5000 }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ config: mocks.config })) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { fetchAquariusPools, startAquariusIngester } from '../ingest/venues/aquarius' + +const mockPair = { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, +} + +describe('fetchAquariusPools', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + it('queries the configured Aquarius API URL for the network', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any, 'https://testnet.example.com/pools/') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://testnet.example.com/pools/?') + ) + }) + + it('falls back to config.aquarius.apiUrl when no override is passed', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(mocks.config.aquarius.apiUrl) + ) + }) + + it('returns an empty array on a non-ok response', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ ok: false }) + + const result = await fetchAquariusPools(mockPair as any) + expect(result).toEqual([]) + }) +}) + +describe('startAquariusIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Aquarius is disabled on the active network', async () => { + mocks.config.aquarius.enabled = false + + await startAquariusIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.aquarius.enabled = true + }) +}) diff --git a/src/__tests__/networkVenueConfig.test.ts b/src/__tests__/networkVenueConfig.test.ts new file mode 100644 index 00000000..67e0644b --- /dev/null +++ b/src/__tests__/networkVenueConfig.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for per-network venue configuration (Soroswap / Aquarius / Reflector). + * + * Each test resets modules and re-imports `../config` after mutating + * `process.env` so the lazy per-network cache in config.ts is rebuilt from + * the env vars set for that test. + */ + +const ENV_KEYS = [ + 'STELLAR_NETWORK', + 'SOROSWAP_ENABLED_TESTNET', + 'SOROSWAP_ENABLED_MAINNET', + 'SOROSWAP_TOKEN_LIST_URL', + 'SOROSWAP_TOKEN_LIST_URL_TESTNET', + 'AQUARIUS_ENABLED_TESTNET', + 'AQUARIUS_ENABLED_MAINNET', + 'AQUARIUS_API_URL', + 'REFLECTOR_CONTRACT_ID_TESTNET', + 'REFLECTOR_CONTRACT_ID_MAINNET', + 'REFLECTOR_ENABLED_TESTNET', +] + +async function loadConfig() { + vi.resetModules() + return await import('../config') +} + +describe('per-network venue config', () => { + const originalEnv: Record = {} + + beforeEach(() => { + for (const key of ENV_KEYS) originalEnv[key] = process.env[key] + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } + }) + + it('defaults Aquarius to disabled on testnet and enabled on mainnet', async () => { + delete process.env.AQUARIUS_ENABLED_TESTNET + delete process.env.AQUARIUS_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(false) + expect(getNetworkConfig('mainnet').aquarius.enabled).toBe(true) + }) + + it('respects an explicit AQUARIUS_ENABLED_TESTNET=true override', async () => { + process.env.AQUARIUS_ENABLED_TESTNET = 'true' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(true) + }) + + it('defaults Soroswap to enabled on both networks', async () => { + delete process.env.SOROSWAP_ENABLED_TESTNET + delete process.env.SOROSWAP_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(true) + expect(getNetworkConfig('mainnet').soroswap.enabled).toBe(true) + }) + + it('disables Soroswap on a network when explicitly set to false', async () => { + process.env.SOROSWAP_ENABLED_TESTNET = 'false' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(false) + }) + + it('resolves a per-network token-list URL override before falling back to the shared default', async () => { + delete process.env.SOROSWAP_TOKEN_LIST_URL + process.env.SOROSWAP_TOKEN_LIST_URL_TESTNET = 'https://example.com/testnet-tokens.json' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.tokenListUrl).toBe( + 'https://example.com/testnet-tokens.json' + ) + expect(getNetworkConfig('mainnet').soroswap.tokenListUrl).toBe( + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + ) + }) + + it('disables the Reflector oracle when no contract id is configured for the network', async () => { + delete process.env.REFLECTOR_CONTRACT_ID_TESTNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').oracle.reflectorContractId).toBe('') + expect(getNetworkConfig('testnet').oracle.enabled).toBe(false) + }) + + it('enables the Reflector oracle on mainnet where a default contract id exists', async () => { + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('mainnet').oracle.reflectorContractId).not.toBe('') + expect(getNetworkConfig('mainnet').oracle.enabled).toBe(true) + }) + + it('resolves a custom Aquarius API URL override', async () => { + process.env.AQUARIUS_API_URL = 'https://example.com/aquarius/' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + expect(getNetworkConfig('mainnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + }) +}) diff --git a/src/__tests__/soroswapEnabled.test.ts b/src/__tests__/soroswapEnabled.test.ts new file mode 100644 index 00000000..46b190f3 --- /dev/null +++ b/src/__tests__/soroswapEnabled.test.ts @@ -0,0 +1,44 @@ +/** + * Verifies the Soroswap ingester respects the per-network enable flag, + * skipping the polling loop entirely when Soroswap has no usable deployment + * on the active network. + */ + +const mocks = vi.hoisted(() => ({ + config: { + soroswap: { + enabled: true, + factoryAddress: 'CFACTORY', + tokenListUrl: 'https://example.com/tokens.json', + pollIntervalMs: 60000, + }, + network: { passphrase: 'Test SDF Network ; September 2015' }, + rpc: { url: 'https://soroban-testnet.stellar.org' }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ config: mocks.config })) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { startSoroswapIngester } from '../ingesters/soroswap' + +describe('startSoroswapIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Soroswap is disabled on the active network', async () => { + mocks.config.soroswap.enabled = false + + await startSoroswapIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.soroswap.enabled = true + }) +}) diff --git a/src/config.ts b/src/config.ts index aea52778..8d8ffd2d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,12 +18,24 @@ export interface NetworkConfig { passphrase: string } soroswap: { + /** Whether Soroswap has a usable deployment on this network. */ + enabled: boolean /** Soroswap factory contract address for this network. */ factoryAddress: string + /** Soroswap token-list URL for this network. */ + tokenListUrl: string /** How often to poll Soroswap pool reserves in ms. */ pollIntervalMs: number } + aquarius: { + /** Whether Aquarius has a usable deployment on this network. */ + enabled: boolean + /** Aquarius AMM pools API base URL for this network. */ + apiUrl: string + } oracle: { + /** Whether the Reflector oracle is deployed on this network. */ + enabled: boolean /** Reflector oracle contract ID for this network. */ reflectorContractId: string } @@ -106,6 +118,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2' : 'CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3') + // Soroswap token-list is a single canonical list covering both networks by + // default, but can be overridden per network (e.g. a testnet-specific list). + const soroswapTokenListUrl = + process.env[`SOROSWAP_TOKEN_LIST_URL_${suffix}`] || + process.env.SOROSWAP_TOKEN_LIST_URL || + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + + const soroswapEnabled = + (process.env[`SOROSWAP_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' + const soroswapPollMs = parseInt( process.env[`SOROSWAP_POLL_INTERVAL_MS_${suffix}`] || process.env.SOROSWAP_POLL_INTERVAL_MS || @@ -113,6 +135,17 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { 10 ) + // ── Aquarius ────────────────────────────────────────────────────────────── + // Aquarius only runs on Stellar classic mainnet today — there is no public + // testnet deployment, so it is disabled there by default. + const aquariusApiUrl = + process.env[`AQUARIUS_API_URL_${suffix}`] || + process.env.AQUARIUS_API_URL || + 'https://amm.aquarius.network/api/v1/pools/' + + const aquariusEnabled = + (process.env[`AQUARIUS_ENABLED_${suffix}`] ?? (network === 'mainnet' ? 'true' : 'false')).toLowerCase() !== 'false' + // ── Reflector oracle ────────────────────────────────────────────────────── const reflectorContractId = process.env[`REFLECTOR_CONTRACT_ID_${suffix}`] || @@ -121,6 +154,10 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA' : '') + const oracleEnabled = + (process.env[`REFLECTOR_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + reflectorContractId !== '' + // ── Watched pairs ───────────────────────────────────────────────────────── const rawPairs = process.env[`WATCHED_PAIRS_${suffix}`] || @@ -132,10 +169,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { rpc: { url: rpcUrl }, network: { passphrase }, soroswap: { + enabled: soroswapEnabled, factoryAddress: soroswapFactory, + tokenListUrl: soroswapTokenListUrl, pollIntervalMs: soroswapPollMs, }, - oracle: { reflectorContractId }, + aquarius: { + enabled: aquariusEnabled, + apiUrl: aquariusApiUrl, + }, + oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), } } @@ -229,6 +272,7 @@ export const config = { get rpc() { return resolveNetwork(activeNetwork).rpc }, get network() { return resolveNetwork(activeNetwork).network }, get soroswap() { return resolveNetwork(activeNetwork).soroswap }, + get aquarius() { return resolveNetwork(activeNetwork).aquarius }, get oracle() { return resolveNetwork(activeNetwork).oracle }, get pairs() { return resolveNetwork(activeNetwork).pairs }, diff --git a/src/ingest/oracles/reflector.ts b/src/ingest/oracles/reflector.ts index 6bcee44b..84392465 100644 --- a/src/ingest/oracles/reflector.ts +++ b/src/ingest/oracles/reflector.ts @@ -44,6 +44,8 @@ export interface ReflectorPrice { * Returns null when the contract is unreachable or the asset is unknown. */ export async function fetchReflectorPrice(assetCode: string): Promise { + if (!config.oracle.enabled) return null + try { const rpc = getRpc() const contract = new Contract(REFLECTOR_CONTRACT_ID) diff --git a/src/ingest/venues/aquarius.ts b/src/ingest/venues/aquarius.ts index a609d726..5e0fab3a 100644 --- a/src/ingest/venues/aquarius.ts +++ b/src/ingest/venues/aquarius.ts @@ -14,8 +14,6 @@ import { upsertPricePoints } from '../../db' import { dispatchPriceUpdate } from '../../webhookDispatcher' import type { WatchedPair } from '../../types' -const AQUARIUS_AMM_API = 'https://amm.aquarius.network/api/v1/pools/' - const lastPrice = new Map() interface AquariusPool { @@ -28,7 +26,10 @@ interface AquariusListResponse { results?: AquariusPool[] } -export async function fetchAquariusPools(pair: WatchedPair): Promise { +export async function fetchAquariusPools( + pair: WatchedPair, + apiUrl: string = config.aquarius.apiUrl +): Promise { try { const assetAStr = pair.assetA.issuer ? `${pair.assetA.code}:${pair.assetA.issuer}` @@ -41,7 +42,7 @@ export async function fetchAquariusPools(pair: WatchedPair): Promise { + if (!config.aquarius.enabled) { + console.log('[aquarius] Aquarius is disabled on this network — ingester not started') + return + } + console.log(`[aquarius] Starting Aquarius AMM ingester for ${getActivePairs().length} pairs`) while (true) { for (const pair of getActivePairs()) { diff --git a/src/ingesters/soroswap.ts b/src/ingesters/soroswap.ts index b1accc2f..fbfa1248 100644 --- a/src/ingesters/soroswap.ts +++ b/src/ingesters/soroswap.ts @@ -27,9 +27,6 @@ import type { WatchedPair } from '../types' // ── Constants ───────────────────────────────────────────────────────────────── -const SOROSWAP_TOKEN_LIST_URL = - 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' - // Ephemeral fee-payer account (no real funds needed for simulation) const FEE_PAYER_KEYPAIR = Keypair.random() @@ -71,9 +68,11 @@ function getRpc(): SorobanRpc.Server { * Fetch Soroswap token list. Returns an empty array on failure so the ingester * degrades gracefully without affecting other ingesters. */ -export async function fetchSoroswapTokenList(): Promise { +export async function fetchSoroswapTokenList( + tokenListUrl: string = config.soroswap.tokenListUrl +): Promise { try { - const res = await fetch(SOROSWAP_TOKEN_LIST_URL) + const res = await fetch(tokenListUrl) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as SoroswapTokenList return Array.isArray(data.tokens) ? data.tokens : [] @@ -327,7 +326,13 @@ async function sleep(ms: number): Promise { * Fault-isolated: a crash is caught by the caller (restartIngester in index.ts). */ export async function startSoroswapIngester(): Promise { + if (!config.soroswap.enabled) { + console.log('[soroswap] Soroswap is disabled on this network — ingester not started') + return + } + const factoryAddress = config.soroswap.factoryAddress + const tokenListUrl = config.soroswap.tokenListUrl const pollInterval = config.soroswap.pollIntervalMs console.log( @@ -336,7 +341,7 @@ export async function startSoroswapIngester(): Promise { while (true) { const pairs = getActivePairs() - const tokens = await fetchSoroswapTokenList() + const tokens = await fetchSoroswapTokenList(tokenListUrl) if (tokens.length === 0) { console.warn('[soroswap] Token list empty — skipping poll cycle') From 90ec7fe2b672dc83242679ee4126b33c2575607d Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Sat, 29 Aug 2026 20:07:34 +0100 Subject: [PATCH 06/12] feat: parameterize Horizon and Soroban RPC clients per network Every SDK client used to talk to a Stellar network was a module-level singleton bound to whatever network was active at import time: bestRoute.ts and the SDEX/AMM ingesters each built their own Horizon.Server(config.horizon.url), and the Soroswap/Reflector ingesters built a SorobanRpc.Server(config.rpc.url). That made it impossible to run any of these against more than one network in the same process. Adds src/network/clients.ts with getHorizonServer(network) and getRpcServer(network) factories that cache one client per network, so repeated calls for the same network reuse an instance while different networks always resolve to distinct clients. getBestRoute, the SDEX and AMM ingester functions, and the Soroswap pool/reserve/factory lookups now take an explicit network parameter (defaulting to the currently active network for backward compatibility) and resolve their client and network passphrase through the new factories instead of a fixed singleton. The Reflector oracle adapter does the same and now treats a network with no configured contract id as an oracle with no data rather than attempting a simulation against an empty contract address. The raw fetch() URL templates in the AMM ingester and Soroswap token-list fetch also resolve their base URL per network rather than through the shared config singleton. Fixes two test files (bestRoute.test.ts, aggregator.property.test.ts) whose @stellar/stellar-sdk mocks omitted the Networks export used by config.ts; this previously went unnoticed because both files failed to load entirely under the prior singleton wiring, so none of their test cases ever ran. --- src/__tests__/bestRoute.test.ts | 4 +- src/__tests__/networkClients.test.ts | 48 +++++++++++++++++++++ src/__tests__/soroswapIngester.test.ts | 3 +- src/aggregator/bestRoute.ts | 21 ++++++---- src/ingest/oracles/reflector.ts | 42 +++++++++---------- src/ingesters/amm.ts | 27 ++++++------ src/ingesters/sdex.ts | 19 ++++----- src/ingesters/soroswap.ts | 58 ++++++++++++++------------ src/network/clients.ts | 37 ++++++++++++++++ tests/aggregator.property.test.ts | 4 +- 10 files changed, 181 insertions(+), 82 deletions(-) create mode 100644 src/__tests__/networkClients.test.ts create mode 100644 src/network/clients.ts diff --git a/src/__tests__/bestRoute.test.ts b/src/__tests__/bestRoute.test.ts index e1a793f0..7002e9c6 100644 --- a/src/__tests__/bestRoute.test.ts +++ b/src/__tests__/bestRoute.test.ts @@ -10,9 +10,11 @@ vi.mock('../db', () => ({ } })) -vi.mock('@stellar/stellar-sdk', () => { +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() const callFn = vi.fn() return { + ...actual, Horizon: { Server: vi.fn(function() { return { diff --git a/src/__tests__/networkClients.test.ts b/src/__tests__/networkClients.test.ts new file mode 100644 index 00000000..0d09246d --- /dev/null +++ b/src/__tests__/networkClients.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for the per-network Horizon / Soroban RPC client factories. + */ + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Horizon: { Server: vi.fn(function (url: string) { return { __url: url } }) }, + rpc: { Server: vi.fn(function (url: string) { return { __url: url } }) }, + } +}) + +import { getHorizonServer, getRpcServer } from '../network/clients' + +describe('getHorizonServer', () => { + it('returns distinct clients for different networks', () => { + const testnet = getHorizonServer('testnet') + const mainnet = getHorizonServer('mainnet') + + expect(testnet).not.toBe(mainnet) + expect((testnet as any).__url).toContain('testnet') + expect((mainnet as any).__url).not.toContain('testnet') + }) + + it('returns the same cached client for repeated calls on the same network', () => { + const first = getHorizonServer('testnet') + const second = getHorizonServer('testnet') + + expect(first).toBe(second) + }) +}) + +describe('getRpcServer', () => { + it('returns distinct clients for different networks', () => { + const testnet = getRpcServer('testnet') + const mainnet = getRpcServer('mainnet') + + expect(testnet).not.toBe(mainnet) + }) + + it('returns the same cached client for repeated calls on the same network', () => { + const first = getRpcServer('mainnet') + const second = getRpcServer('mainnet') + + expect(first).toBe(second) + }) +}) diff --git a/src/__tests__/soroswapIngester.test.ts b/src/__tests__/soroswapIngester.test.ts index 5cedc722..2bc06ae7 100644 --- a/src/__tests__/soroswapIngester.test.ts +++ b/src/__tests__/soroswapIngester.test.ts @@ -164,7 +164,8 @@ describe('ingestPair', () => { expect(mockFetchPools).toHaveBeenCalledWith( factory, xlmToken.address, - usdcToken.address + usdcToken.address, + expect.any(String) ) }) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index 5b116397..9a27420f 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -1,10 +1,9 @@ -import { Horizon, Asset } from '@stellar/stellar-sdk' -import { config } from '../config' +import { Asset } from '@stellar/stellar-sdk' +import { activeNetwork, type NetworkName } from '../config' +import { getHorizonServer } from '../network/clients' import type { AssetId, RouteInfo } from '../types' import { pgPool } from '../db' -const horizonServer = new Horizon.Server(config.horizon.url) - function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() return new Asset(asset.code, asset.issuer) @@ -36,11 +35,16 @@ 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 getHorizonServer(network) .strictSendPaths(stellarAssetA, amount.toString(), [stellarAssetB]) .call() if (paths.records.length === 0) return 0 @@ -55,10 +59,11 @@ export async function getBestRoute( assetA: AssetId, assetB: AssetId, pairKey: string, - amount: number = 1000 + amount: number = 1000, + 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/ingest/oracles/reflector.ts b/src/ingest/oracles/reflector.ts index 6bcee44b..a77a9fac 100644 --- a/src/ingest/oracles/reflector.ts +++ b/src/ingest/oracles/reflector.ts @@ -17,22 +17,12 @@ import { nativeToScVal, Account, } from '@stellar/stellar-sdk' -import { config } from '../../config' - -// Reflector oracle contract address — resolved per active network via config. -// Set REFLECTOR_CONTRACT_ID_TESTNET / REFLECTOR_CONTRACT_ID_MAINNET (or the -// legacy REFLECTOR_CONTRACT_ID fallback) in your environment. -const REFLECTOR_CONTRACT_ID = config.oracle.reflectorContractId +import { activeNetwork, getNetworkConfig, type NetworkName } from '../../config' +import { getRpcServer } from '../../network/clients' // Ephemeral fee payer — simulation only, no real funds needed const FEE_PAYER = Keypair.random() -let _rpc: SorobanRpc.Server | null = null -function getRpc(): SorobanRpc.Server { - _rpc ??= new SorobanRpc.Server(config.rpc.url, { allowHttp: true }) - return _rpc -} - export interface ReflectorPrice { asset: string price: number @@ -40,18 +30,25 @@ export interface ReflectorPrice { } /** - * Fetch the latest price for a given asset code from the Reflector oracle. - * Returns null when the contract is unreachable or the asset is unknown. + * Fetch the latest price for a given asset code from the Reflector oracle on + * the given network. Returns null when the contract is unreachable, the + * network has no Reflector deployment configured, or the asset is unknown. */ -export async function fetchReflectorPrice(assetCode: string): Promise { +export async function fetchReflectorPrice( + assetCode: string, + network: NetworkName = activeNetwork +): Promise { + const netConfig = getNetworkConfig(network) + if (!netConfig.oracle.reflectorContractId) return null + try { - const rpc = getRpc() - const contract = new Contract(REFLECTOR_CONTRACT_ID) + const rpc = getRpcServer(network) + const contract = new Contract(netConfig.oracle.reflectorContractId) const account = new Account(FEE_PAYER.publicKey(), '0') const tx = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: config.network.passphrase, + networkPassphrase: netConfig.network.passphrase, }) .addOperation( contract.call('lastprice', nativeToScVal(assetCode, { type: 'symbol' })) @@ -86,13 +83,16 @@ export async function fetchReflectorPrice(assetCode: string): Promise() const CACHE_TTL_MS = 60_000 -export async function getCachedReflectorPrice(asset: string): Promise { - const key = asset.toUpperCase() +export async function getCachedReflectorPrice( + asset: string, + network: NetworkName = activeNetwork +): Promise { + const key = `${network}:${asset.toUpperCase()}` const entry = _cache.get(key) if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) { return entry.price } - const fresh = await fetchReflectorPrice(key) + const fresh = await fetchReflectorPrice(asset, network) if (fresh) { _cache.set(key, { price: fresh.price, fetchedAt: Date.now() }) return fresh.price diff --git a/src/ingesters/amm.ts b/src/ingesters/amm.ts index f2aae240..8093c836 100644 --- a/src/ingesters/amm.ts +++ b/src/ingesters/amm.ts @@ -1,16 +1,13 @@ -import { Horizon } from '@stellar/stellar-sdk' import { amm_snapshots_total, trades_ingested_total, last_trade_timestamp } from '../metrics' -import { config } from '../config' +import { config, activeNetwork, getNetworkConfig, type NetworkName } from '../config' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints, getIndexerCursor, setIndexerCursor, prisma } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' import type { WatchedPair } from '../types' -const horizonServer = new Horizon.Server(config.horizon.url) - const lastPrice = new Map() -export async function fetchPools(pair: WatchedPair): Promise { +export async function fetchPools(pair: WatchedPair, network: NetworkName = activeNetwork): Promise { try { // Use Horizon's reserves filter to find pools for this specific pair const assetAStr = pair.assetA.issuer @@ -26,7 +23,7 @@ export async function fetchPools(pair: WatchedPair): Promise { params.set('limit', '10') const response = await fetch( - `${config.horizon.url}/liquidity_pools?${params.toString()}` + `${getNetworkConfig(network).horizon.url}/liquidity_pools?${params.toString()}` ) const data = await response.json() as any if (!data._embedded?.records) return [] @@ -97,13 +94,17 @@ export async function snapshotPool(pool: any, pair: WatchedPair): Promise } } -export async function ingestPoolTrades(pool: any, pair: WatchedPair): Promise { - const stateId = `amm:${pool.id}` +export async function ingestPoolTrades( + pool: any, + pair: WatchedPair, + network: NetworkName = activeNetwork +): Promise { + const stateId = `amm:${network}:${pool.id}` const cursor = await getIndexerCursor(stateId) ?? '0' try { const response = await fetch( - `${config.horizon.url}/liquidity_pools/${pool.id}/trades?cursor=${cursor}&limit=${config.indexer.ammPageSize}&order=asc` + `${getNetworkConfig(network).horizon.url}/liquidity_pools/${pool.id}/trades?cursor=${cursor}&limit=${config.indexer.ammPageSize}&order=asc` ) const data = await response.json() as any const records = data._embedded?.records ?? [] @@ -161,17 +162,17 @@ async function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) } -export async function startAMMIngester(): Promise { - console.log(`[amm] Starting AMM ingester for ${getActivePairs().length} pairs`) +export async function startAMMIngester(network: NetworkName = activeNetwork): Promise { + console.log(`[amm] Starting AMM ingester for ${getActivePairs().length} pairs on ${network}`) while (true) { for (const pair of getActivePairs()) { - const pools = await fetchPools(pair) + const pools = await fetchPools(pair, network) console.log(`[amm] ${pair.pairKey}: found ${pools.length} AMM pools`) await Promise.all(pools.map(async pool => { await snapshotPool(pool, pair) - await ingestPoolTrades(pool, pair) + await ingestPoolTrades(pool, pair, network) })) } await sleep(config.indexer.pollIntervalMs) diff --git a/src/ingesters/sdex.ts b/src/ingesters/sdex.ts index 02185728..e5022cc9 100644 --- a/src/ingesters/sdex.ts +++ b/src/ingesters/sdex.ts @@ -1,13 +1,12 @@ -import { Horizon, Asset } from '@stellar/stellar-sdk' +import { Asset } from '@stellar/stellar-sdk' import { trades_ingested_total, last_trade_timestamp } from '../metrics' -import { config } from '../config' +import { config, activeNetwork, type NetworkName } from '../config' +import { getHorizonServer } from '../network/clients' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints, getIndexerCursor, setIndexerCursor } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' import type { WatchedPair } from '../types' -const horizonServer = new Horizon.Server(config.horizon.url) - // Last seen price per pairKey — used for threshold crossing detection const lastPrice = new Map() @@ -16,15 +15,15 @@ function toAsset(asset: { code: string; issuer: string | null }): Asset { return new Asset(asset.code, asset.issuer) } -export async function ingestPair(pair: WatchedPair): Promise { - const stateId = `sdex:${pair.pairKey}` +export async function ingestPair(pair: WatchedPair, network: NetworkName = activeNetwork): Promise { + const stateId = `sdex:${network}:${pair.pairKey}` const cursor = await getIndexerCursor(stateId) ?? '0' try { const assetA = toAsset(pair.assetA) const assetB = toAsset(pair.assetB) - const trades = await horizonServer + const trades = await getHorizonServer(network) .trades() .forAssetPair(assetA, assetB) .cursor(cursor) @@ -87,11 +86,11 @@ async function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) } -export async function startSDEXIngester(): Promise { - console.log(`[sdex] Starting SDEX ingester for ${getActivePairs().length} pairs`) +export async function startSDEXIngester(network: NetworkName = activeNetwork): Promise { + console.log(`[sdex] Starting SDEX ingester for ${getActivePairs().length} pairs on ${network}`) while (true) { - await Promise.all(getActivePairs().map(pair => ingestPair(pair))) + await Promise.all(getActivePairs().map(pair => ingestPair(pair, network))) await sleep(config.indexer.pollIntervalMs) } } diff --git a/src/ingesters/soroswap.ts b/src/ingesters/soroswap.ts index b1accc2f..c9156dd6 100644 --- a/src/ingesters/soroswap.ts +++ b/src/ingesters/soroswap.ts @@ -19,7 +19,8 @@ import { scValToNative, Account, } from '@stellar/stellar-sdk' -import { config } from '../config' +import { activeNetwork, getNetworkConfig, type NetworkName } from '../config' +import { getRpcServer } from '../network/clients' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' @@ -55,16 +56,6 @@ export interface PoolEntry { tokenB: SoroswapToken } -// ── RPC client (lazy-initialised so tests can skip it) ──────────────────────── - -let _rpc: SorobanRpc.Server | null = null -function getRpc(): SorobanRpc.Server { - if (!_rpc) { - _rpc = new SorobanRpc.Server(config.rpc.url, { allowHttp: true }) - } - return _rpc -} - // ── Token-list helpers ──────────────────────────────────────────────────────── /** @@ -92,14 +83,15 @@ export async function fetchSoroswapTokenList(): Promise { export async function fetchPoolsFromFactory( factoryAddress: string, tokenA: string, - tokenB: string + tokenB: string, + network: NetworkName = activeNetwork ): Promise { try { - const rpc = getRpc() + const rpc = getRpcServer(network) const factory = new Contract(factoryAddress) const account = new Account(FEE_PAYER_KEYPAIR.publicKey(), '0') const networkPassphrase = - config.network.passphrase ?? Networks.PUBLIC + getNetworkConfig(network).network.passphrase ?? Networks.PUBLIC const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -146,14 +138,15 @@ export async function fetchPoolsFromFactory( * Returns null on any RPC error. */ export async function fetchPoolReserves( - poolAddress: string + poolAddress: string, + network: NetworkName = activeNetwork ): Promise<[bigint, bigint] | null> { try { - const rpc = getRpc() + const rpc = getRpcServer(network) const pool = new Contract(poolAddress) const account = new Account(FEE_PAYER_KEYPAIR.publicKey(), '0') const networkPassphrase = - config.network.passphrase ?? Networks.PUBLIC + getNetworkConfig(network).network.passphrase ?? Networks.PUBLIC const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -212,10 +205,11 @@ export function calcSpotPrice(reserveA: bigint, reserveB: bigint): number { export async function ingestPool( poolEntry: PoolEntry, pair: WatchedPair, - _fetchReserves = fetchPoolReserves + _fetchReserves: (poolAddress: string, network?: NetworkName) => Promise<[bigint, bigint] | null> = fetchPoolReserves, + network: NetworkName = activeNetwork ): Promise { try { - const reserves = await _fetchReserves(poolEntry.poolAddress) + const reserves = await _fetchReserves(poolEntry.poolAddress, network) if (!reserves) return const [reserveA, reserveB] = reserves @@ -277,7 +271,13 @@ export async function ingestPair( pair: WatchedPair, tokens: SoroswapToken[], factoryAddress: string, - _fetchPools = fetchPoolsFromFactory + _fetchPools: ( + factoryAddress: string, + tokenA: string, + tokenB: string, + network?: NetworkName + ) => Promise = fetchPoolsFromFactory, + network: NetworkName = activeNetwork ): Promise { const tokenA = tokens.find( (t) => t.symbol.toUpperCase() === pair.assetA.code.toUpperCase() @@ -296,7 +296,8 @@ export async function ingestPair( const poolAddresses = await _fetchPools( factoryAddress, tokenA.address, - tokenB.address + tokenB.address, + network ) if (!poolAddresses.length) { @@ -310,7 +311,9 @@ export async function ingestPair( poolAddresses.map((addr) => ingestPool( { poolAddress: addr, tokenA, tokenB }, - pair + pair, + fetchPoolReserves, + network ) ) ) @@ -326,12 +329,13 @@ async function sleep(ms: number): Promise { * Start the Soroswap AMM ingester. Runs as an infinite polling loop. * Fault-isolated: a crash is caught by the caller (restartIngester in index.ts). */ -export async function startSoroswapIngester(): Promise { - const factoryAddress = config.soroswap.factoryAddress - const pollInterval = config.soroswap.pollIntervalMs +export async function startSoroswapIngester(network: NetworkName = activeNetwork): Promise { + const netConfig = getNetworkConfig(network) + const factoryAddress = netConfig.soroswap.factoryAddress + const pollInterval = netConfig.soroswap.pollIntervalMs console.log( - `[soroswap] Starting Soroswap ingester | factory=${factoryAddress} | interval=${pollInterval}ms` + `[soroswap] Starting Soroswap ingester on ${network} | factory=${factoryAddress} | interval=${pollInterval}ms` ) while (true) { @@ -342,7 +346,7 @@ export async function startSoroswapIngester(): Promise { console.warn('[soroswap] Token list empty — skipping poll cycle') } else { await Promise.all( - pairs.map((pair) => ingestPair(pair, tokens, factoryAddress)) + pairs.map((pair) => ingestPair(pair, tokens, factoryAddress, fetchPoolsFromFactory, network)) ) } diff --git a/src/network/clients.ts b/src/network/clients.ts new file mode 100644 index 00000000..ad1c92f9 --- /dev/null +++ b/src/network/clients.ts @@ -0,0 +1,37 @@ +/** + * Per-network Horizon / Soroban RPC client factories. + * + * Every ingester and aggregator that talks to a Stellar network needs a + * Horizon.Server or SorobanRpc.Server bound to that network's endpoints. + * Previously these were created once as module-level singletons bound to + * whatever network was active at import time, which made it impossible to + * run ingesters against more than one network in the same process. + * + * getHorizonServer(network) / getRpcServer(network) return a client cached + * per network so repeated calls for the same network reuse one instance, + * while different networks always resolve to distinct clients. + */ + +import { Horizon, rpc as SorobanRpc } from '@stellar/stellar-sdk' +import { getNetworkConfig, type NetworkName } from '../config' + +const horizonClients = new Map() +const rpcClients = new Map() + +export function getHorizonServer(network: NetworkName): Horizon.Server { + let client = horizonClients.get(network) + if (!client) { + client = new Horizon.Server(getNetworkConfig(network).horizon.url) + horizonClients.set(network, client) + } + return client +} + +export function getRpcServer(network: NetworkName): SorobanRpc.Server { + let client = rpcClients.get(network) + if (!client) { + client = new SorobanRpc.Server(getNetworkConfig(network).rpc.url, { allowHttp: true }) + rpcClients.set(network, client) + } + return client +} diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts index a842e619..4f86ec78 100644 --- a/tests/aggregator.property.test.ts +++ b/tests/aggregator.property.test.ts @@ -10,9 +10,11 @@ vi.mock('../src/db', () => ({ }, })) -vi.mock('@stellar/stellar-sdk', () => { +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() const callFn = vi.fn() return { + ...actual, Horizon: { Server: vi.fn(function () { return { From 7222f3ab4ff5d0fc5d7d907e18b0d796359f43f4 Mon Sep 17 00:00:00 2001 From: Elizabethxxx Date: Sat, 29 Aug 2026 23:08:53 +0100 Subject: [PATCH 07/12] feat: add Bazaar discovery catalog with GET /discovery/resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the catalog-browsing half of the x402 bazaar extension (specs/extensions/bazaar.md in x402-foundation/x402) so Stellar-denominated services are discoverable the same way any other x402 facilitator's listings are, rather than only through whichever multi-chain facilitator happens to carry them. Schema: a single bazaar_resources table (Postgres, via Prisma) holds both HTTP endpoints and MCP tools, discriminated by `type`. HTTP resources are identified by (network, url, httpMethod); MCP resources by (network, url, toolName) — the spec's required tuple of (resource.url, input.toolName), additionally scoped by network. That scoping is a deliberate deviation from the spec's literal tuple: Lens is dual-network, so the same (url, toolName) pair can legitimately be two different listings (different payTo/asset) on mainnet vs testnet, which the spec's tuple alone can't express. Each row's `accepts` and `extensions.bazaar` are stored verbatim so the discovery response round-trips the exact PaymentRequirements and bazaar.info/schema/routeTemplate a resource advertised in its own 402 response. Route: GET /discovery/resources implements the spec's six filters — type, payTo, network, extensions, limit, offset. `network` accepts either our NetworkName ("mainnet"/"testnet") or the CAIP-2 id the spec's examples use ("stellar:pubnet"/"stellar:testnet"); `extensions` matches on presence of the given key in a resource's declared extension list. Pagination is plain offset/limit ordered by (createdAt desc, id desc) — the id tiebreaker keeps a paginated walk stable across pages even if new rows are inserted concurrently, since ordering by createdAt alone could otherwise shift which row lands on a given offset. Interop: the response shape mirrors the resource/accepts/extensions.bazaar structure used in a 402 PaymentRequired response (per the spec, since no literal JSON example is given for the list endpoint) — the same shape any x402 facilitator's bazaar listings use, so a Stellar listing here looks structurally identical to one from an EVM-chain facilitator except for its network id and asset. The one addition beyond the spec is the network column and its dual-format filter matching described above, needed because Lens serves two networks from one process. The bazaar.md spec text does not give a concrete JSON example for GET /discovery/resources or specify AND/OR semantics for combining multiple filters — this implementation applies all provided filters as an AND, which is the reading consistent with /discovery/search "mirroring the list endpoint" as a narrowing search. closes #128 --- README.md | 1 + prisma/schema.prisma | 92 +++++++++++ src/__tests__/bazaarCatalog.test.ts | 248 ++++++++++++++++++++++++++++ src/__tests__/discovery.test.ts | 108 ++++++++++++ src/bazaar/catalog.ts | 190 +++++++++++++++++++++ src/bazaar/types.ts | 87 ++++++++++ src/index.ts | 2 + src/routes/discovery.ts | 16 ++ 8 files changed, 744 insertions(+) create mode 100644 src/__tests__/bazaarCatalog.test.ts create mode 100644 src/__tests__/discovery.test.ts create mode 100644 src/bazaar/catalog.ts create mode 100644 src/bazaar/types.ts create mode 100644 src/routes/discovery.ts diff --git a/README.md b/README.md index 7cd291f1..5e647afd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | GET | `/pools` | Active AMM pools being watched | | GET | `/pairs` | Watched trading pairs | | GET | `/status` | Indexer health | +| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | ### GraphQL Available at `/graphql` with GraphiQL IDE at `/graphiql`. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c36ea2fa..afac294f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,6 +99,98 @@ model PairConfig { @@map("pair_configs") } +/// A single x402-discoverable resource in the Bazaar catalog — either an +/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension +/// (specs/extensions/bazaar.md in x402-foundation/x402). +/// +/// HTTP and MCP resources share one table (discriminated by `type`) rather +/// than two, because a discovery listing is fundamentally "a resource with +/// payment requirements and a bazaar.info blob" regardless of transport — +/// splitting them would require the discovery query to UNION two tables on +/// every filter combination for no benefit, since the two types are never +/// looked up via different access patterns. +model BazaarResource { + id String @id @default(uuid()) + + /// "http" | "mcp" — discriminates which of the two input shapes below applies. + type String + + /// Which Stellar network this listing settles on ("mainnet" | "testnet"). + /// Mirrors config.ts's NetworkName so a listing is never ambiguous about + /// which network's payTo/asset it refers to. + network String + + /// The protected resource URL (`resource.url` in the spec). For MCP this is + /// the MCP server endpoint, not the tool itself — the tool is disambiguated + /// by `mcpToolName` below. + url String + + /// `resource.description` — human-readable description of the resource. + description String? + + /// `resource.mimeType`. + mimeType String? @map("mime_type") + + /// Optional service metadata the spec allows on `resource`. + serviceName String? @map("service_name") + tags String[] @default([]) + iconUrl String? @map("icon_url") + + /// MCP tool identifier (`input.toolName`). Null for HTTP resources. + /// Per the spec, MCP resources are keyed on the TUPLE of (resource.url, + /// input.toolName) since multiple tools multiplex over one server endpoint. + /// We additionally scope that tuple by `network` (see @@unique below) — + /// a deliberate deviation, called out in the PR: since Lens is + /// dual-network, the same (url, toolName) pair can legitimately exist + /// once per network with a different payTo/asset in `accepts`, and the + /// spec's tuple alone can't express that without collapsing them. + mcpToolName String? @map("mcp_tool_name") + + /// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources. + httpMethod String? @map("http_method") + + /// Full `accepts[]` payment requirements array (scheme/network/amount/asset/ + /// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response + /// can round-trip the exact PaymentRequirements the resource advertised. + accepts Json + + /// The `payTo` address extracted from accepts[0] for indexed filtering. + /// Denormalized on write because Postgres cannot efficiently index into a + /// JSON array element without a functional/GIN index per accepted scheme, + /// and payTo is the one field the spec calls out as a top-level filter. + payTo String @map("pay_to") + + /// `extensions.bazaar.info` — discovery metadata (input type, params, output). + bazaarInfo Json @map("bazaar_info") + + /// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`. + bazaarSchema Json @map("bazaar_schema") + + /// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for + /// dynamic HTTP routes, used by the facilitator to consolidate listings. + routeTemplate String? @map("route_template") + + /// Any other declared extension keys beyond "bazaar" (spec's `extensions` + /// filter matches on presence of a key here, "bazaar" always included). + extensionKeys String[] @default(["bazaar"]) @map("extension_keys") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + // HTTP resources are keyed on (network, url, httpMethod); MCP resources are + // keyed on (network, url, mcpToolName) per the spec's tuple. Postgres + // treats NULLs as distinct in a unique index, so these two constraints + // don't collide with each other for a row that only populates one side. + @@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity") + @@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity") + // Covers the six spec filters (type, payTo, network, extensions via + // extensionKeys, plus limit/offset) and keeps pagination stable — see + // routes/discovery.ts, which always orders by (createdAt, id). + @@index([network, type, payTo, createdAt(sort: Desc), id]) + @@index([extensionKeys], type: Gin) + @@map("bazaar_resources") +} + model Webhook { id String @id @default(uuid()) url String diff --git a/src/__tests__/bazaarCatalog.test.ts b/src/__tests__/bazaarCatalog.test.ts new file mode 100644 index 00000000..8952d82b --- /dev/null +++ b/src/__tests__/bazaarCatalog.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockUpsert: vi.fn(), + mockDeleteMany: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + bazaarResource: { + findMany: mockFindMany, + count: mockCount, + upsert: mockUpsert, + deleteMany: mockDeleteMany, + }, + }, +})) + +import { + parseDiscoveryFilters, + queryDiscoveryResources, + registerBazaarResource, +} from '../bazaar/catalog' +import type { RegisterBazaarResourceInput } from '../bazaar/types' + +beforeEach(() => { + mockFindMany.mockReset().mockResolvedValue([]) + mockCount.mockReset().mockResolvedValue(0) + mockUpsert.mockReset().mockResolvedValue({}) + mockDeleteMany.mockReset().mockResolvedValue({ count: 0 }) +}) + +describe('parseDiscoveryFilters', () => { + it('defaults limit to 50 and offset to 0', () => { + const filters = parseDiscoveryFilters({}) + expect(filters.limit).toBe(50) + expect(filters.offset).toBe(0) + }) + + it('clamps limit to a maximum of 200', () => { + const filters = parseDiscoveryFilters({ limit: '10000' }) + expect(filters.limit).toBe(200) + }) + + it('rejects a negative or zero limit, falling back to the default', () => { + expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50) + expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50) + }) + + it('rejects a negative offset, falling back to 0', () => { + expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0) + }) + + it('passes through a valid offset', () => { + expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25) + }) + + it('only accepts "http" or "mcp" for type, dropping anything else', () => { + expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http') + expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp') + expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined() + }) + + it('passes through payTo, network, and extensions filters', () => { + const filters = parseDiscoveryFilters({ + payTo: 'GABC', + network: 'stellar:pubnet', + extensions: 'bazaar', + }) + expect(filters.payTo).toBe('GABC') + expect(filters.network).toBe('stellar:pubnet') + expect(filters.extensions).toBe('bazaar') + }) +}) + +describe('queryDiscoveryResources', () => { + it('filters by type', async () => { + await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) }) + ) + }) + + it('filters by payTo', async () => { + await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('resolves a CAIP-2 network filter to the internal NetworkName', async () => { + await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) }) + ) + }) + + it('resolves stellar:testnet to testnet', async () => { + await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) }) + ) + }) + + it('passes through an unrecognized network filter verbatim', async () => { + await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) }) + ) + }) + + it('filters by extension key presence', async () => { + await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) }) + ) + }) + + it('applies limit and offset for pagination', async () => { + await queryDiscoveryResources({ limit: 10, offset: 20 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 20 }) + ) + }) + + it('orders by createdAt desc with id as a stable tiebreaker', async () => { + await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }) + ) + }) + + it('returns total count alongside the page of resources', async () => { + mockCount.mockResolvedValue(137) + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.total).toBe(137) + }) + + it('maps a stored row back into the spec resource/accepts/extensions shape', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/price', + description: 'Unified price feed', + mimeType: 'application/json', + serviceName: 'Lens', + tags: ['price', 'stellar'], + iconUrl: 'https://lens.example/icon.png', + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: { type: 'object' }, + routeTemplate: null, + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources).toHaveLength(1) + const listing = result.resources[0] + expect(listing.resource.url).toBe('https://lens.example/price') + expect(listing.resource.serviceName).toBe('Lens') + expect(listing.accepts[0].payTo).toBe('GPAY') + expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } }) + expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate') + }) + + it('includes routeTemplate when present', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/users/123', + description: null, + mimeType: null, + serviceName: null, + tags: [], + iconUrl: null, + accepts: [], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: {}, + routeTemplate: '/users/:userId', + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId') + }) +}) + +describe('registerBazaarResource', () => { + const httpInput: RegisterBazaarResourceInput = { + type: 'http', + network: 'mainnet', + resource: { url: 'https://lens.example/price' }, + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } }, + } + + const mcpInput: RegisterBazaarResourceInput = { + type: 'mcp', + network: 'testnet', + resource: { url: 'https://lens.example/mcp' }, + accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }], + bazaar: { + info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } }, + schema: { type: 'object' }, + }, + } + + it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } }, + }) + ) + }) + + it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => { + await registerBazaarResource(mcpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } }, + }) + ) + }) + + it('rejects registration when accepts[] is empty', async () => { + await expect( + registerBazaarResource({ ...httpInput, accepts: [] }) + ).rejects.toThrow(/payTo/) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('denormalizes payTo from accepts[0] onto the row', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('always includes "bazaar" in extensionKeys by default', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) }) + ) + }) +}) diff --git a/src/__tests__/discovery.test.ts b/src/__tests__/discovery.test.ts new file mode 100644 index 00000000..3871d368 --- /dev/null +++ b/src/__tests__/discovery.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQueryDiscoveryResources, mockParseDiscoveryFilters } = vi.hoisted(() => ({ + mockQueryDiscoveryResources: vi.fn(), + mockParseDiscoveryFilters: vi.fn(), +})) + +vi.mock('../bazaar/catalog', () => ({ + queryDiscoveryResources: mockQueryDiscoveryResources, + parseDiscoveryFilters: mockParseDiscoveryFilters, +})) + +import { registerDiscoveryRoutes } from '../routes/discovery' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerDiscoveryRoutes(app) + await app.ready() + return app +} + +beforeEach(() => { + mockParseDiscoveryFilters.mockReset().mockImplementation((q: any) => ({ + type: q.type, + payTo: q.payTo, + network: q.network, + extensions: q.extensions, + limit: q.limit ? Number(q.limit) : 50, + offset: q.offset ? Number(q.offset) : 0, + })) + mockQueryDiscoveryResources.mockReset().mockResolvedValue({ + resources: [], + limit: 50, + offset: 0, + total: 0, + }) +}) + +describe('GET /discovery/resources', () => { + it('returns 200 without any auth header — discovery is public', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + expect(res.statusCode).toBe(200) + }) + + it('returns the resources/limit/offset/total envelope', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + const body = res.json() + expect(body).toHaveProperty('resources') + expect(body).toHaveProperty('limit') + expect(body).toHaveProperty('offset') + expect(body).toHaveProperty('total') + }) + + it('forwards the type filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?type=mcp' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ type: 'mcp' })) + }) + + it('forwards the payTo filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?payTo=GABC123' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ payTo: 'GABC123' })) + }) + + it('forwards the network filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?network=stellar:pubnet' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ network: 'stellar:pubnet' })) + }) + + it('forwards the extensions filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?extensions=bazaar' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ extensions: 'bazaar' })) + }) + + it('forwards limit and offset from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?limit=10&offset=20' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, offset: 20 })) + }) + + it('returns resources returned by the catalog query verbatim', async () => { + mockQueryDiscoveryResources.mockResolvedValue({ + resources: [ + { + resource: { url: 'https://lens.example/price' }, + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + extensions: { bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: {} } }, + }, + ], + limit: 50, + offset: 0, + total: 1, + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + const body = res.json() + expect(body.resources).toHaveLength(1) + expect(body.total).toBe(1) + expect(body.resources[0].resource.url).toBe('https://lens.example/price') + }) +}) diff --git a/src/bazaar/catalog.ts b/src/bazaar/catalog.ts new file mode 100644 index 00000000..9e0a9a4b --- /dev/null +++ b/src/bazaar/catalog.ts @@ -0,0 +1,190 @@ +import { prisma } from '../db' +import type { NetworkName } from '../config' +import type { + BazaarResourceListing, + DiscoveryFilters, + DiscoveryResponse, + RegisterBazaarResourceInput, +} from './types' + +/** CAIP-2 network ids used by @x402/stellar and returned in `accepts[].network`. */ +const STELLAR_NETWORK_IDS: Record = { + mainnet: 'stellar:pubnet', + testnet: 'stellar:testnet', +} + +/** + * Registers (or updates) a resource in the Bazaar catalog. + * + * HTTP resources are identified by (network, url, httpMethod); MCP resources + * by (network, url, toolName) — the tuple the spec requires because multiple + * tools can multiplex over one MCP server endpoint. Re-registering the same + * identity updates the existing row instead of creating a duplicate, so a + * resource server can safely call this on every startup. + */ +export async function registerBazaarResource(input: RegisterBazaarResourceInput): Promise { + const payTo = input.accepts[0]?.payTo + if (!payTo) { + throw new Error('registerBazaarResource: accepts[] must contain at least one payment requirement with payTo') + } + + const base = { + type: input.type, + network: input.network, + url: input.resource.url, + description: input.resource.description ?? null, + mimeType: input.resource.mimeType ?? null, + serviceName: input.resource.serviceName ?? null, + tags: input.resource.tags ?? [], + iconUrl: input.resource.iconUrl ?? null, + mcpToolName: input.type === 'mcp' ? (input.bazaar.info.input as { toolName: string }).toolName : null, + httpMethod: input.type === 'http' ? (input.bazaar.info.input as { method: string }).method : null, + accepts: input.accepts as object, + payTo, + bazaarInfo: input.bazaar.info as object, + bazaarSchema: input.bazaar.schema as object, + routeTemplate: input.bazaar.routeTemplate ?? null, + extensionKeys: input.extensionKeys ?? ['bazaar'], + } + + if (input.type === 'mcp') { + const mcpToolName = base.mcpToolName as string + await prisma.bazaarResource.upsert({ + where: { + bazaarMcpIdentity: { network: input.network, url: input.resource.url, mcpToolName }, + }, + create: base, + update: base, + }) + } else { + const httpMethod = base.httpMethod as string + await prisma.bazaarResource.upsert({ + where: { + bazaarHttpIdentity: { network: input.network, url: input.resource.url, httpMethod }, + }, + create: base, + update: base, + }) + } +} + +export async function removeBazaarResource(network: NetworkName, url: string, key?: string): Promise { + await prisma.bazaarResource.deleteMany({ + where: { + network, + url, + OR: [{ httpMethod: key ?? undefined }, { mcpToolName: key ?? undefined }], + }, + }) +} + +/** + * Parses and clamps query-string filters for GET /discovery/resources. + * `limit` defaults to 50 and is clamped to [1, 200] to bound catalog scans; + * `offset` defaults to 0 and cannot be negative. + */ +export function parseDiscoveryFilters(query: Record): DiscoveryFilters { + const rawLimit = Number(query.limit) + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 200) : 50 + + const rawOffset = Number(query.offset) + const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0 + + const type = query.type === 'http' || query.type === 'mcp' ? query.type : undefined + const payTo = typeof query.payTo === 'string' && query.payTo.length > 0 ? query.payTo : undefined + const network = typeof query.network === 'string' && query.network.length > 0 ? query.network : undefined + const extensions = typeof query.extensions === 'string' && query.extensions.length > 0 ? query.extensions : undefined + + return { type, payTo, network, extensions, limit, offset } +} + +/** + * Maps the spec's `network` filter value (a CAIP-2 id, e.g. "stellar:pubnet" + * or "stellar:testnet") to our internal NetworkName column value. Falls back + * to matching the raw string directly so a facilitator that passes our + * NetworkName values (or any future non-Stellar CAIP-2 id we don't recognize + * yet) does not silently match nothing. + */ +function resolveNetworkFilter(network: string | undefined): string | undefined { + if (!network) return undefined + const entry = (Object.entries(STELLAR_NETWORK_IDS) as [NetworkName, string][]) + .find(([, caip2]) => caip2 === network) + return entry ? entry[0] : network +} + +function toListing(row: { + url: string + description: string | null + mimeType: string | null + serviceName: string | null + tags: string[] + iconUrl: string | null + accepts: unknown + bazaarInfo: unknown + bazaarSchema: unknown + routeTemplate: string | null + extensionKeys: string[] +}): BazaarResourceListing { + const extensions: BazaarResourceListing['extensions'] = { + bazaar: { + info: row.bazaarInfo as BazaarResourceListing['extensions']['bazaar']['info'], + schema: row.bazaarSchema as Record, + ...(row.routeTemplate ? { routeTemplate: row.routeTemplate } : {}), + }, + } + + return { + resource: { + url: row.url, + ...(row.description ? { description: row.description } : {}), + ...(row.mimeType ? { mimeType: row.mimeType } : {}), + ...(row.serviceName ? { serviceName: row.serviceName } : {}), + ...(row.tags.length > 0 ? { tags: row.tags } : {}), + ...(row.iconUrl ? { iconUrl: row.iconUrl } : {}), + }, + accepts: row.accepts as BazaarResourceListing['accepts'], + extensions, + } +} + +/** + * GET /discovery/resources — paginated catalog query implementing the six + * spec filters: + * - type: exact match on "http" | "mcp" + * - payTo: exact match against the resource's payment recipient + * - network: matches either our NetworkName ("mainnet"/"testnet") or the + * CAIP-2 id the spec's examples use ("stellar:pubnet"/"stellar:testnet") + * - extensions: matches resources that declare the given extension key + * (the spec's example is "bazaar", which every row declares by default) + * - limit / offset: standard offset pagination + * + * Ordering is (createdAt DESC, id DESC) — a stable tiebreaker on the primary + * key — so that concurrent inserts during a paginated walk never shift + * already-returned rows to a different page (the classic offset-pagination + * hazard when ordering by a non-unique column alone). + */ +export async function queryDiscoveryResources(filters: DiscoveryFilters): Promise { + const where = { + ...(filters.type ? { type: filters.type } : {}), + ...(filters.payTo ? { payTo: filters.payTo } : {}), + ...(filters.network ? { network: resolveNetworkFilter(filters.network) } : {}), + ...(filters.extensions ? { extensionKeys: { has: filters.extensions } } : {}), + } + + const [rows, total] = await Promise.all([ + prisma.bazaarResource.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: filters.limit, + skip: filters.offset, + }), + prisma.bazaarResource.count({ where }), + ]) + + return { + resources: rows.map(toListing), + limit: filters.limit, + offset: filters.offset, + total, + } +} diff --git a/src/bazaar/types.ts b/src/bazaar/types.ts new file mode 100644 index 00000000..9f30346f --- /dev/null +++ b/src/bazaar/types.ts @@ -0,0 +1,87 @@ +import type { NetworkName } from '../config' + +/** + * Shapes for the x402 `bazaar` discovery extension + * (specs/extensions/bazaar.md in x402-foundation/x402). + * + * These mirror the spec's `resource` + `accepts` + `extensions.bazaar` shape + * used in a 402 PaymentRequired response — a discovery listing is that same + * shape, catalogued. + */ + +export interface BazaarResourceInfo { + url: string + description?: string + mimeType?: string + serviceName?: string + tags?: string[] + iconUrl?: string +} + +export interface BazaarPaymentRequirement { + scheme: string + network: string + amount: string + asset: string + payTo: string + maxTimeoutSeconds: number + extra?: Record +} + +export type BazaarHttpInput = + | { type: 'http'; method: 'GET' | 'HEAD' | 'DELETE'; queryParams?: Record; pathParams?: Record } + | { type: 'http'; method: 'POST' | 'PUT' | 'PATCH'; bodyType: string; body?: Record; pathParams?: Record } + +export interface BazaarMcpInput { + type: 'mcp' + toolName: string + inputSchema: Record + transport?: 'streamable-http' | 'sse' +} + +export interface BazaarInfo { + input: BazaarHttpInput | BazaarMcpInput + output?: Record +} + +export interface BazaarExtensionDeclaration { + info: BazaarInfo + schema: Record + routeTemplate?: string +} + +/** A single item in the GET /discovery/resources response. */ +export interface BazaarResourceListing { + resource: BazaarResourceInfo + accepts: BazaarPaymentRequirement[] + extensions: { + bazaar: BazaarExtensionDeclaration + [key: string]: unknown + } +} + +/** Input for registering a new listing in the catalog. */ +export interface RegisterBazaarResourceInput { + type: 'http' | 'mcp' + network: NetworkName + resource: BazaarResourceInfo + accepts: BazaarPaymentRequirement[] + bazaar: BazaarExtensionDeclaration + extensionKeys?: string[] +} + +export interface DiscoveryFilters { + type?: 'http' | 'mcp' + payTo?: string + network?: string + extensions?: string + limit: number + offset: number +} + +export interface DiscoveryResponse { + resources: BazaarResourceListing[] + limit: number + offset: number + total: number +} diff --git a/src/index.ts b/src/index.ts index 1d12cb85..dfdf094b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { registerVolumeRoutes } from './routes/volumes' import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' +import { registerDiscoveryRoutes } from './routes/discovery' import { fanOutManager } from './ws/fanout' import { startSDEXIngester } from './ingesters/sdex' @@ -121,6 +122,7 @@ async function main() { await registerBenchmarkRoutes(app) await registerOracleRoutes(app) await registerBasketRoutes(app) + await registerDiscoveryRoutes(app) await registerGraphQL(app) await registerWebSocket(app) diff --git a/src/routes/discovery.ts b/src/routes/discovery.ts new file mode 100644 index 00000000..6b91b7e9 --- /dev/null +++ b/src/routes/discovery.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify' +import { parseDiscoveryFilters, queryDiscoveryResources } from '../bazaar/catalog' + +/** + * Registers GET /discovery/resources — the Bazaar catalog-browsing endpoint + * from the x402 `bazaar` extension (specs/extensions/bazaar.md in + * x402-foundation/x402). Public and un-gated: discovery has to work before a + * client has any payment method configured, the same reasoning that keeps + * GET /supported un-gated (see routes/facilitator.ts). + */ +export async function registerDiscoveryRoutes(app: FastifyInstance) { + app.get('/discovery/resources', { config: { public: true } }, async (req) => { + const filters = parseDiscoveryFilters(req.query as Record) + return queryDiscoveryResources(filters) + }) +} From 03a89523668aa26bda75efccf63cd9238197a908 Mon Sep 17 00:00:00 2001 From: Anambraboi-1 Date: Sun, 30 Aug 2026 00:05:33 +0100 Subject: [PATCH 08/12] feat: MCP discovery server --- examples/mcp-server/run.ts | 37 ++ package-lock.json | 831 ++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/mcp/codes.ts | 18 + src/mcp/server.ts | 175 ++++++++ tests/mcp.test.ts | 123 ++++++ 6 files changed, 1177 insertions(+), 13 deletions(-) create mode 100644 examples/mcp-server/run.ts create mode 100644 src/mcp/codes.ts create mode 100644 src/mcp/server.ts create mode 100644 tests/mcp.test.ts diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts new file mode 100644 index 00000000..e3b2b688 --- /dev/null +++ b/examples/mcp-server/run.ts @@ -0,0 +1,37 @@ +import 'dotenv/config'; +import { BazaarMcpServer } from '../../src/mcp/server'; +import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +import { ExactStellarScheme } from '@x402/stellar/exact/client'; +import { createEd25519Signer } from '@x402/stellar'; +import { Keypair } from '@stellar/stellar-sdk'; + +async function main() { + const secretKey = process.env.MCP_AGENT_SECRET_KEY || Keypair.random().secret(); + const network = process.env.MCP_AGENT_NETWORK || 'stellar:testnet'; + + const signer = createEd25519Signer(secretKey, network); + const client = new ExactStellarScheme(signer); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { + schemes: [ + { + network: 'stellar:*', + client, + } + ] + }); + + const server = new BazaarMcpServer({ + fetchWithPayment + }); + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + console.error(`[mcp-server] Agent network: ${network}`); + + await server.run(); +} + +main().catch(err => { + console.error('[mcp-server] Fatal error:', err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index f2f1552d..e6729f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", "@x402/core": "^2.8.0", + "@x402/fetch": "^2.24.0", "@x402/stellar": "^2.8.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", @@ -26,7 +28,8 @@ "pg": "^8.0.0", "prom-client": "^15.1.3", "uuid": "^9.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.5.4" }, "devDependencies": { "@changesets/cli": "^2.31.0", @@ -1279,6 +1282,18 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -1416,6 +1431,58 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -2228,6 +2295,42 @@ "zod": "^3.24.2" } }, + "node_modules/@x402/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/fetch": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.24.0.tgz", + "integrity": "sha512-jhopac/aRWh3CYpDFjMxIzo/raH0ncmdDK2op6RzMoWsH3wj9/ejJylTv+Q22epReWLZ9AI35LEymN66B0MqNw==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.24.0" + } + }, + "node_modules/@x402/fetch/node_modules/@x402/core": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.24.0.tgz", + "integrity": "sha512-zKFbG+RgUhBXT9RcBQZGPW1cZXL9xpiTv68XSSuO1SMoobudmVvxYgP/bhoxJuAoqJjjEceeAhT4/EcA/wolrQ==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/fetch/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@x402/stellar": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.10.0.tgz", @@ -2296,6 +2399,35 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2588,6 +2720,43 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2671,6 +2840,15 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2787,6 +2965,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2807,12 +2994,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -2829,7 +3042,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2995,6 +3207,21 @@ "node": ">= 6" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3142,6 +3369,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -3169,6 +3405,15 @@ "node": ">=12.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3179,6 +3424,93 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -3444,6 +3776,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -3523,6 +3876,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -3777,6 +4148,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3818,7 +4198,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3900,6 +4279,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -3954,6 +4342,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -4020,7 +4414,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4062,6 +4455,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4107,6 +4509,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -4530,6 +4938,19 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mercurius": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/mercurius/-/mercurius-16.9.0.tgz", @@ -4574,6 +4995,18 @@ ], "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4756,6 +5189,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -4777,6 +5239,27 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4797,6 +5280,18 @@ "node": ">=14.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4900,6 +5395,15 @@ "quansync": "^0.2.7" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4914,7 +5418,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4936,6 +5439,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5168,6 +5681,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5325,6 +5847,28 @@ "node": "^16 || ^18 || >=20" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5381,6 +5925,22 @@ "node": ">= 16" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -5446,6 +6006,34 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -5633,6 +6221,22 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5712,7 +6316,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/secure-json-parse": { @@ -5743,6 +6346,67 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -5796,7 +6460,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5809,12 +6472,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6203,6 +6937,53 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6248,6 +7029,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", @@ -6273,6 +7063,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.9", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", @@ -6445,7 +7244,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6532,13 +7330,22 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 5c0813ce..2e74c06f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "release": "changeset tag", "oracle:relay": "tsx examples/oracle-relay/relay.ts", "alert:bot": "tsx examples/alert-bot/alert-bot.ts", + "mcp:server": "tsx examples/mcp-server/run.ts", "key:issue": "tsx scripts/issue-api-key.ts", "db:push": "prisma db push", "db:generate": "prisma generate" @@ -23,9 +24,11 @@ "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", "@x402/core": "^2.8.0", + "@x402/fetch": "^2.24.0", "@x402/stellar": "^2.8.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", @@ -37,7 +40,8 @@ "pg": "^8.0.0", "prom-client": "^15.1.3", "uuid": "^9.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.5.4" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts new file mode 100644 index 00000000..a9631225 --- /dev/null +++ b/src/mcp/codes.ts @@ -0,0 +1,18 @@ +export const MCP_ERROR_CODES = { + ERR_PAYMENT_REJECTED: 'ERR_PAYMENT_REJECTED', + ERR_INSUFFICIENT_BALANCE: 'ERR_INSUFFICIENT_BALANCE', + ERR_EXPIRED_AUTHORISATION: 'ERR_EXPIRED_AUTHORISATION', + ERR_MISSING_TRUSTLINE: 'ERR_MISSING_TRUSTLINE', + ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', + ERR_NO_RESULTS: 'ERR_NO_RESULTS', + ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' +} as const; + +export type McpErrorCode = keyof typeof MCP_ERROR_CODES; + +export interface McpErrorResponse { + isError: true; + code: McpErrorCode; + reason: string; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 00000000..e2d0fce9 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,175 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; + +export interface McpServerConfig { + fetchWithPayment: typeof fetch; +} + +export class BazaarMcpServer { + private server: Server; + private fetchWithPayment: typeof fetch; + + constructor(config: McpServerConfig) { + this.fetchWithPayment = config.fetchWithPayment; + this.server = new Server( + { + name: 'bazaar-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'bazaar_search', + description: 'Search for resources in a Stellar Bazaar node', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The base URL of the Bazaar API', + }, + query: { + type: 'string', + description: 'Optional search query', + }, + }, + required: ['url'], + }, + }, + { + name: 'paid_call', + description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The full URL of the resource to fetch', + }, + method: { + type: 'string', + description: 'HTTP method (default: GET)', + }, + }, + required: ['url'], + }, + }, + ], + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'bazaar_search') { + const url = String(request.params.arguments?.url); + const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; + + try { + const endpoint = query + ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` + : `${url.replace(/\/$/, '')}/discovery/resources`; + + const res = await fetch(endpoint); + + if (!res.ok) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); + } + + const data = await res.json(); + if ((Array.isArray(data) && data.length === 0) || (data.resources && data.resources.length === 0)) { + return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); + } + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); + } + } + + if (request.params.name === 'paid_call') { + const url = String(request.params.arguments?.url); + const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; + + try { + const res = await this.fetchWithPayment(url, { method }); + + if (!res.ok) { + if (res.status === 402) { + return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + } + return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + } + + const text = await res.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + return { + content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], + }; + } catch (e: any) { + return this.handlePaidCallError(e); + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); + }); + } + + private handlePaidCallError(e: any) { + const msg = String(e.message || e).toLowerCase(); + + if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { + return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + } + + if (msg.includes('trustline') || msg.includes('op_no_trust')) { + return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); + } + + if (msg.includes('insufficient') || msg.includes('underfunded')) { + return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); + } + + if (msg.includes('fetch') || msg.includes('network')) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); + } + + return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + } + + private createErrorResponse(code: McpErrorCode, reason: string) { + // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + const payload = { + isError: true, + code, + reason, + }; + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: true, + }; + } + + async run() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 00000000..8b979ad6 --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from 'vitest'; +import { BazaarMcpServer } from '../src/mcp/server'; +import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +describe('BazaarMcpServer', () => { + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, + // or by overriding setRequestHandler to capture our handler. + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + + // Re-setup handlers to capture the handler + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + }); + + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + }); + + it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => 'Payment Required' + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + }); + + it('should return successfully on a successful call', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: "hello" }) + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBeFalsy(); + const parsedText = JSON.parse(response.content[0].text); + expect(parsedText.success).toBe(true); + }); +}); From f1509ec5a9bdb2cf4988d52fa9a14e713341a16e Mon Sep 17 00:00:00 2001 From: Anambraboi-1 Date: Sun, 30 Aug 2026 00:28:13 +0100 Subject: [PATCH 09/12] feat: MCP discovery server search and paid-call agent tools demo --- examples/mcp-server/agent.ts | 135 +++++++++++++++++++++++++++++++++++ examples/mcp-server/run.ts | 17 +++-- package.json | 1 + 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 examples/mcp-server/agent.ts diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts new file mode 100644 index 00000000..7d47263d --- /dev/null +++ b/examples/mcp-server/agent.ts @@ -0,0 +1,135 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'path'; +import Fastify from 'fastify'; + +/** + * This is a runnable example of an agent connecting to the Bazaar MCP server. + * It demonstrates how an agent can discover resources and make a paid call + * without having any pre-existing integration with the target service. + */ +async function main() { + // 1. Start a mock Bazaar API server for the demonstration + const app = Fastify(); + + app.get('/discovery/search', async (request, reply) => { + const q = (request.query as any).q; + return { + resources: [ + { + name: 'Mock Price Feed', + description: `Price data for ${q}`, + url: 'http://localhost:3000/api/v1/price' + } + ] + }; + }); + + // A mock 402 endpoint. + // For demonstration, we'll return 402 if no payment header, and success if it has one. + app.get('/api/v1/price', async (request, reply) => { + const authHeader = request.headers['authorization']; + if (!authHeader || !authHeader.startsWith('L402 ')) { + // Return a 402 Payment Required with mock challenge + reply.status(402).header('WWW-Authenticate', 'L402 macaroons="mock", invoice="mock"').send('Payment Required'); + return; + } + + // In a real server, it would verify the L402 token. + // Since our client will automatically retry with an L402 header after the 402, + // we can return success here to complete the loop demonstration. + // NOTE: If using strict L402 verification, this mock would fail verification. + // However, for this demonstration, we just return the data. + return { + asset: 'XLM', + price: 0.15, + timestamp: new Date().toISOString() + }; + }); + + await app.listen({ port: 3000 }); + console.log('[mock-server] Started mock Bazaar API on http://localhost:3000'); + + console.log('[agent] Starting MCP server child process...'); + + // Start the MCP server as a child process + const transport = new StdioClientTransport({ + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['tsx', path.join(__dirname, 'run.ts')], + }); + + const client = new Client( + { + name: 'bazaar-agent-example', + version: '1.0.0', + }, + { + capabilities: {}, + } + ); + + await client.connect(transport); + console.log('[agent] Connected to Bazaar MCP server.'); + + const tools = await client.listTools(); + console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); + + const BAZAAR_URL = 'http://localhost:3000'; + + console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); + console.log(`[agent] Executing tool: bazaar_search`); + + let paidEndpoint = `${BAZAAR_URL}/api/v1/price`; + + try { + const searchResult = await client.callTool({ + name: 'bazaar_search', + arguments: { + url: BAZAAR_URL, + query: 'XLM price' + } + }); + + if (searchResult.isError) { + console.log(`[agent] Search failed. Code: ${JSON.parse(searchResult.content[0].text).code}`); + } else { + console.log('[agent] Search result:', searchResult.content[0].text); + const data = JSON.parse(searchResult.content[0].text); + if (data.resources && data.resources.length > 0) { + paidEndpoint = data.resources[0].url; + } + } + } catch (err: any) { + console.error('[agent] Search error:', err.message); + } + + console.log(`\n[agent] 2. Making a paid call to ${paidEndpoint}...`); + console.log(`[agent] Executing tool: paid_call`); + console.log(`[agent] The MCP server will handle the 402 loop automatically...`); + + try { + const paidResult = await client.callTool({ + name: 'paid_call', + arguments: { + url: paidEndpoint + } + }); + + if (paidResult.isError) { + const errorData = JSON.parse(paidResult.content[0].text); + console.log(`[agent] Paid call failed with deterministic code: ${errorData.code}`); + console.log(`[agent] Reason: ${errorData.reason}`); + } else { + console.log('[agent] Paid call succeeded! Retrieved data:'); + console.log(paidResult.content[0].text); + } + } catch (err: any) { + console.error('[agent] Paid call error:', err.message); + } + + console.log('\n[agent] End to end demonstration complete.'); + await app.close(); + process.exit(0); +} + +main().catch(console.error); diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index e3b2b688..589da807 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -7,16 +7,21 @@ import { Keypair } from '@stellar/stellar-sdk'; async function main() { const secretKey = process.env.MCP_AGENT_SECRET_KEY || Keypair.random().secret(); - const network = process.env.MCP_AGENT_NETWORK || 'stellar:testnet'; + const testnetSigner = createEd25519Signer(secretKey, 'stellar:testnet'); + const pubnetSigner = createEd25519Signer(secretKey, 'stellar:pubnet'); - const signer = createEd25519Signer(secretKey, network); - const client = new ExactStellarScheme(signer); + const testnetClient = new ExactStellarScheme(testnetSigner); + const pubnetClient = new ExactStellarScheme(pubnetSigner); const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { schemes: [ { - network: 'stellar:*', - client, + network: 'stellar:testnet', + client: testnetClient, + }, + { + network: 'stellar:pubnet', + client: pubnetClient, } ] }); @@ -26,7 +31,7 @@ async function main() { }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); - console.error(`[mcp-server] Agent network: ${network}`); + console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); await server.run(); } diff --git a/package.json b/package.json index 2e74c06f..565b3871 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "oracle:relay": "tsx examples/oracle-relay/relay.ts", "alert:bot": "tsx examples/alert-bot/alert-bot.ts", "mcp:server": "tsx examples/mcp-server/run.ts", + "mcp:agent": "tsx examples/mcp-server/agent.ts", "key:issue": "tsx scripts/issue-api-key.ts", "db:push": "prisma db push", "db:generate": "prisma generate" From ee878be3556c29fd05c9a62ca34d38703ced6a76 Mon Sep 17 00:00:00 2001 From: Anambraboi-1 Date: Sun, 30 Aug 2026 10:54:51 +0100 Subject: [PATCH 10/12] Resolve requested changes on PR #145 --- examples/mcp-server/run.ts | 14 +++++++++++--- package.json | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 589da807..45fed468 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -6,14 +6,22 @@ import { createEd25519Signer } from '@x402/stellar'; import { Keypair } from '@stellar/stellar-sdk'; async function main() { - const secretKey = process.env.MCP_AGENT_SECRET_KEY || Keypair.random().secret(); - const testnetSigner = createEd25519Signer(secretKey, 'stellar:testnet'); - const pubnetSigner = createEd25519Signer(secretKey, 'stellar:pubnet'); + const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; + const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; + + if (!testnetKey || !mainnetKey) { + console.error('[mcp-server] Error: MCP_AGENT_SECRET_KEY_TESTNET and MCP_AGENT_SECRET_KEY_MAINNET must be set'); + process.exit(1); + } + + const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); + const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { + maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', schemes: [ { network: 'stellar:testnet', diff --git a/package.json b/package.json index 565b3871..d31b331c 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", - "@x402/core": "^2.8.0", + "@x402/core": "^2.24.0", "@x402/fetch": "^2.24.0", - "@x402/stellar": "^2.8.0", + "@x402/stellar": "^2.24.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", "fastify": "^5.0.0", From 765fb04fb4171c60c7598e0fa2bfad83451db8fe Mon Sep 17 00:00:00 2001 From: Anambraboi-1 Date: Sun, 30 Aug 2026 10:55:26 +0100 Subject: [PATCH 11/12] Update package-lock.json after aligning dependency versions --- package-lock.json | 191 ++++++++++++++++++++++++++++------------------ 1 file changed, 117 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6729f91..59c3e589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lens", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lens", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@fastify/compress": "^8.3.1", "@fastify/cors": "^11.2.0", @@ -15,9 +15,9 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", - "@x402/core": "^2.8.0", + "@x402/core": "^2.24.0", "@x402/fetch": "^2.24.0", - "@x402/stellar": "^2.8.0", + "@x402/stellar": "^2.24.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", "fastify": "^5.0.0", @@ -1580,28 +1580,22 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2287,9 +2281,9 @@ } }, "node_modules/@x402/core": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.10.0.tgz", - "integrity": "sha512-n9Exnt1HN4LFaINaPYhk6Cy3ICBt0e46XN1Uo5i6efIZfIoqP6pY8ONSX/M9bU4F1fpvMj0JZ3xdcBZCiGInfw==", + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.24.0.tgz", + "integrity": "sha512-zKFbG+RgUhBXT9RcBQZGPW1cZXL9xpiTv68XSSuO1SMoobudmVvxYgP/bhoxJuAoqJjjEceeAhT4/EcA/wolrQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" @@ -2313,69 +2307,68 @@ "@x402/core": "~2.24.0" } }, - "node_modules/@x402/fetch/node_modules/@x402/core": { + "node_modules/@x402/stellar": { "version": "2.24.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.24.0.tgz", - "integrity": "sha512-zKFbG+RgUhBXT9RcBQZGPW1cZXL9xpiTv68XSSuO1SMoobudmVvxYgP/bhoxJuAoqJjjEceeAhT4/EcA/wolrQ==", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.24.0.tgz", + "integrity": "sha512-d/FFkgimprqhPbHMK9SAw3qOPZhh7/3ikxOD/TfFkPYpsxtQ+Mzpj0T63N8AATxqh6PT5d1vt9OBzpDIG5bCgw==", "license": "Apache-2.0", "dependencies": { - "zod": "^3.24.2" - } - }, - "node_modules/@x402/fetch/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "@stellar/stellar-sdk": "^16.0.1", + "@x402/core": "~2.24.0" + }, + "engines": { + "node": ">=22.0.0" } }, - "node_modules/@x402/stellar": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.10.0.tgz", - "integrity": "sha512-WUoWwH+imX2CbqPAxnFqEJwMop5PP9APU6ky0ZSV3YYiguHZ8MdvbvxqjbyA1+jtlDgI9JkMdiMUra60sI1aCw==", + "node_modules/@x402/stellar/node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-sdk": "^14.6.1", - "@x402/core": "~2.10.0" + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" } }, - "node_modules/@x402/stellar/node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "node_modules/@x402/stellar/node_modules/@stellar/stellar-sdk": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", + "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", + "bignumber.js": "^11.1.4", "buffer": "^6.0.3", - "sha.js": "^2.4.12" + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, - "node_modules/@x402/stellar/node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "license": "Apache-2.0", + "node_modules/@x402/stellar/node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/@x402/stellar/node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "bin": { - "stellar-js": "bin/stellar-js" + "eventsource-parser": "^3.0.1" }, "engines": { "node": ">=20.0.0" @@ -2428,6 +2421,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2600,13 +2605,14 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -3826,9 +3832,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4184,6 +4190,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -6588,6 +6607,18 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -7012,6 +7043,18 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", From 207c8d064a1b8d569cd318efe562786f7a56bfe9 Mon Sep 17 00:00:00 2001 From: Anambraboi-1 Date: Sun, 30 Aug 2026 13:20:34 +0100 Subject: [PATCH 12/12] Fix type errors and remove unused import --- examples/mcp-server/run.ts | 1 - src/mcp/server.ts | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 45fed468..2b971525 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -3,7 +3,6 @@ import { BazaarMcpServer } from '../../src/mcp/server'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; -import { Keypair } from '@stellar/stellar-sdk'; async function main() { const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e2d0fce9..1c401a79 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -87,7 +87,11 @@ export class BazaarMcpServer { } const data = await res.json(); - if ((Array.isArray(data) && data.length === 0) || (data.resources && data.resources.length === 0)) { + + const hasResources = (d: unknown): d is { resources: unknown[] } => + typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); + + if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); }