Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.*
Expand Down
40 changes: 38 additions & 2 deletions src/__tests__/bestRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
}
})
Expand All @@ -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 () => {
Expand Down Expand Up @@ -107,12 +117,38 @@ 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)

const result = await getBestRoute(assetA, assetB, pairKey, 1000)

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)
})
})
61 changes: 61 additions & 0 deletions src/__tests__/middleware/network.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
77 changes: 77 additions & 0 deletions src/__tests__/middleware/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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, unknown> = {}): string {
const payload = { scheme: 'exact', amount: '$0.10', recipient: PAYMENT_ADDRESS, ...overrides }
return Buffer.from(JSON.stringify(payload)).toString('base64')
Expand All @@ -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 ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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' })
)
})
})
22 changes: 15 additions & 7 deletions src/__tests__/price.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 15 additions & 7 deletions src/__tests__/schemaValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading