Skip to content

Network selector on routes + per-request x402 network - #136

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
ibochivincent-lang:feat/api-network-selector
Aug 30, 2026
Merged

Network selector on routes + per-request x402 network#136
Miracle656 merged 1 commit into
Miracle656:mainfrom
ibochivincent-lang:feat/api-network-selector

Conversation

@ibochivincent-lang

Copy link
Copy Markdown
Contributor

Summary

  • Adds src/middleware/network.ts: a Fastify plugin resolving the per-request Stellar network from a ?network= query param or x-network header, validating it (400 for anything not testnet/mainnet, defaults to testnet), and attaching it to req.network. Registered early in src/index.ts (right after cors/compress, ahead of API-key auth/rate-limit/x402) so everything downstream can read it.
  • src/x402/network.ts: shared per-network helpers (X402_NETWORK_LABEL, paymentAddressFor, getX402ResourceServer) used by both REST x402 gating and the WS endpoint, replacing the old module-load-time STELLAR_NETWORK read.
  • src/middleware/x402.ts and src/api/websocket.ts: x402's network/payTo in the 402 requirements (and the actual verify/settle calls) are now resolved from req.network per request instead of a value computed once at plugin init. Supports optional ORACLE_PAYMENT_ADDRESS_TESTNET / ORACLE_PAYMENT_ADDRESS_MAINNET env vars, falling back to the existing ORACLE_PAYMENT_ADDRESS.
  • src/api/rest.ts + src/aggregator/bestRoute.ts: /price/:assetA/:assetB and /price/:assetA/:assetB/route now look up watched pairs (getNetworkConfig(network).pairs) and live SDEX pricing (a real per-network Horizon call) from the resolved network — so ?network=mainnet genuinely returns mainnet SDEX pricing. The price Redis cache key is network-scoped ({network}:{pairKey}) so testnet/mainnet never collide.
  • /ws resolves+validates the network the same way and rejects (400, closes the socket) a network other than the one this instance is actually streaming, since live price events don't carry a network tag yet.

Scope call — what's not covered

Candles, price history, AMM pool pricing, and the GraphQL resolvers read from Postgres, which has no network column yet — that's the deeper aggregation-layer work the issue's suggested execution calls out ("Thread into pricing/aggregation reads (L048)"). Those endpoints still serve from whichever network this instance is currently configured to ingest (STELLAR_NETWORK). Documented explicitly in the README and in code comments at each of these read sites so it's not lost track of.

Side fix

While wiring getBestRoute's new per-network getNetworkConfig() call, I found tests/aggregator.property.test.ts and src/__tests__/bestRoute.test.ts's @stellar/stellar-sdk mocks were missing a Networks export — pre-existing on main, not something this PR introduces (verified by running the suite against a clean main checkout first). It silently crashed the property test at import time (0 iterations ever ran) and would have crashed bestRoute.test.ts too once getBestRoute started calling getNetworkConfig(). Fixed both mocks; also bumped the property test's timeout to 30s since, now that its 10,000 iterations actually execute, they don't reliably finish in the default 5s under full-suite load.

closes #118

Test plan

  • npx tsc --noEmit — clean.
  • npx vitest run197 passed, 1 skipped, run twice for stability. (Ran against clean main first to confirm which failures were pre-existing vs. introduced by this change.)
  • New tests: src/__tests__/middleware/network.test.ts (selector resolution/validation), new cases in src/__tests__/middleware/x402.test.ts (per-network payTo/network label, invalid network short-circuits before x402 runs, mainnet payment verification), new cases in src/__tests__/bestRoute.test.ts (mainnet Horizon client selection + per-network memoisation).

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.
@drips-wave

drips-wave Bot commented Aug 27, 2026

Copy link
Copy Markdown

@ibochivincent-lang Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the strongest PR in the batch. The part I went looking for a hole in — whether a client could pay on testnet and be served mainnet data — is closed properly.

The payment requirement is bound to req.network end to end: X402_NETWORK_LABEL[network] for the chain id, paymentAddressFor(network) for the payee, and a per-network x402ResourceServer doing the verify. So a testnet payment can only satisfy a testnet request. That's the property that matters and it would have been easy to get wrong.

And this fails closed:

const paymentAddress = paymentAddressFor(network)
if (!paymentAddress) {
  reply.status(402).send({ error: `x402 payments are not configured for network "${network}"` })
  return
}

isX402Configured() is true when either network has an address, so without that guard a request for the unconfigured network would have skipped gating entirely and served paid data for free. Good catch on your part; that's the subtle one.

Other things done right: resolveNetworkName rejects an explicit unknown value with a 400 but treats absent as the default, so existing clients are unaffected; module augmentation gives req.network a real type instead of casts; lazy per-network resource servers mean a network nobody asks for never pays initialize().

One thing worth changing:

// 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'

Hard-coding 'testnet' is inconsistent with the rest of the PR, which is careful to route everything through activeNetwork. On a mainnet deployment where plugin registration order regresses, every request silently becomes testnet — gated by a testnet payment and served testnet prices, with nothing in the logs. It's not a payment-bypass (the two still match), but it is a silent, total downgrade of a mainnet service, caused by a default that exists for test convenience.

req.network ?? activeNetwork gets the same test ergonomics without the production failure mode. Better still, log a warning when the decorator is missing so a registration-order regression is visible.

Two smaller notes, non-blocking:

  • resolveNetworkName accepts any valid network name, not any enabled one. Once #138/#140 land, ?network=mainnet against a testnet-only deployment gets 200 with empty data rather than a clear error. Validating against getEnabledNetworks() would turn that into an honest 400 — same theme as the /supported discussion on #143.
  • Same @ts-ignore on the @x402 imports as #142/#143. Worth one shared fix (likely moduleResolution) rather than three suppressions.

No schema changes, so this is independent of the #141 ordering problem.

@Miracle656
Miracle656 merged commit 5a31014 into Miracle656:main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Network selector on routes + per-request x402 network

2 participants