diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json new file mode 100644 index 0000000..d1cd834 --- /dev/null +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -0,0 +1,19 @@ +{ + "skills": [ + "agentic-payments" + ], + "query": "I have three x402-protected Express routes (GET /signals, GET /market, POST /execute), each at a different USDC price, all behind the same OZ Channels facilitator. Right now I'm calling paymentMiddleware separately for each one. Is there a cleaner way?", + "expected_behavior": [ + "Switches to paymentMiddlewareFromConfig from @x402/express with a single route-keyed object (keys like \"GET /signals\") instead of one paymentMiddleware/x402ResourceServer call per route", + "States that a request whose method+path doesn't match any key falls through to next() unpriced, not a 402 or an error", + "Puts description (and mimeType, when set) at the route-config level, not nested inside accepts — PaymentOption has no description field, so nesting it there compiles but the 402 response serves it empty", + "Does not crash the process at startup when the recipient/payTo env var is unset — wraps middleware initialization in a check instead, and fails CLOSED per request: every request under the paid mount point gets a 503, never a silent 200", + "Resolves payTo through the same recipient-resolution function as mpp.md (recovering a secret key pasted where the public key belongs, rejecting a malformed G... via StrKey.isValidEd25519PublicKey), not a bare process.env.STELLAR_RECIPIENT read — payTo is echoed back unvalidated in every 402 response body, and an unrejected malformed value would reach paymentMiddlewareFromConfig() as real config instead of tripping the fail-closed fallback", + "Mounts the payment middleware under its own path prefix (e.g. app.use(\"/paid\", x402Middleware)) rather than at the app root, and does NOT try to hand-replicate @x402/core's own route matching (method uppercasing, path normalization, wildcard/:param/[param] regex compilation) to decide which unconfigured requests deserve a 503 — every request that reaches the mounted middleware is a paid route by construction, so the unconfigured fallback can respond 503 unconditionally instead of risking a case, encoding, or dynamic-route variant slipping through free", + "Moves the actual route handlers (app.get/app.post for /signals, /market, /execute) under the same /paid prefix as the middleware mount, not just the middleware itself — a handler left registered at the old top-level path never passes through the payment middleware at all and serves its content free regardless of whether PAY_TO is configured", + "Sets an explicit fallback for the facilitator URL using || (e.g. process.env.FACILITATOR_URL || \"https://channels.openzeppelin.com/x402/testnet\"), NOT ?? — ?? only falls back on null/undefined, so FACILITATOR_URL= (set to an empty string in a .env file) passes \"\" straight through, which is falsy inside @x402/core's own 'config?.url || DEFAULT_FACILITATOR_URL' check and silently lands on the library's generic x402.org fallback anyway, sending the OZ_API_KEY Bearer token to the wrong operator and, on mainnet, hitting a facilitator with no stellar:pubnet entry at all" + ], + "machine_checkable": [ + "Generated server passes tsc --noEmit / node --check against @x402/express, @x402/core, @x402/stellar" + ] +} diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json new file mode 100644 index 0000000..6847fc2 --- /dev/null +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -0,0 +1,21 @@ +{ + "skills": [ + "agentic-payments" + ], + "query": "My MPP charge server throws at startup in a fresh environment because STELLAR_RECIPIENT isn't set yet, which breaks CI. I also want to add Session mode later without duplicating the whole server, and I want a way for an agent to check which payment intents are actually live before it makes a request. What's the production-safe way to structure this?", + "expected_behavior": [ + "Wraps recipient resolution so the process never throws at import time: recovers when a secret key (S...) was pasted where the public key belongs by deriving the public key and warning loudly, and disables the affected middleware (not the process) when the value is unset or unparseable", + "Gives Charge and Session mode separate Mppx instances, each initialized only when its own full config is present, so adding Session later is additive, not a rewrite", + "Fails CLOSED per request when an intent's own instance is null — the route middleware returns a non-200 (e.g. 503), never a plain next() that lets the route respond with its normal content unpriced", + "Exposes a runtime /info endpoint reporting each intent's true initialized state (enabled: !!instance), not a static capability list, and does not call it a health check — it reports configuration/initialization state, not whether the RPC or facilitator it depends on is currently reachable", + "For Session mode's commitmentKey, converts the stored hex (MPP_COMMITMENT_KEY) into a Stellar G... address before passing it to stellar.channel() — the library validates it as a Stellar address, not raw ed25519 bytes", + "Validates MPP_COMMITMENT_KEY against a strict regex (e.g. /^[0-9a-f]{64}$/i) on the raw string BEFORE decoding, not a length check on the decoded bytes — Buffer.from(x, 'hex') stops at the first invalid character instead of throwing, so 64 valid hex chars followed by garbage still decodes to exactly 32 bytes and would pass a decoded-length check", + "Never throws when MPP_COMMITMENT_KEY is malformed — catches it, logs the error, and leaves sessionMppx null (Session fails closed the same way an unset env var already does) without affecting chargeMppx, which must stay up regardless of Session's configuration state", + "Wires recipient and currency into stellar.channel()'s config when available, not just channel and commitmentKey — both are optional in the library's types but exist specifically so it can reject a channel that would settle to the wrong account or pay out the wrong token", + "Gives Session its own route-middleware factory (e.g. mppSessionMiddleware) mirroring the Charge one, rather than calling sessionMppx.channel(...) directly as route middleware — sessionMppx is null when Session isn't configured, and evaluating that call at route-registration time (which runs at import time) would throw immediately and crash the whole process, chargeMppx included", + "Includes FEE_PAYER_SECRET in the gate that decides whether sessionMppx is constructed, not just MPP_CHANNEL_CONTRACT/commitmentKey/recipient/MPP_SECRET_KEY — channel.Parameters' own feePayer doc comment says it's \"Required when handling close credential actions,\" so omitting it from the gate lets sessionMppx construct successfully and /info report Session as live right up until close() is actually called and throws", + "Validates MPP_CHANNEL_CONTRACT with StrKey.isValidContract() before including it in the gate and passing it to stellar.channel(), the same validate-before-use pattern as commitmentKey and feePayerSigner — channel() only validates store, not the channel address, so a typo'd contract ID still builds sessionMppx and still reports Session as live in /info, then throws deep inside the SDK on the first paid request instead of failing at boot", + "Validates FEE_PAYER_SECRET with StrKey.isValidEd25519SecretSeed() before calling Keypair.fromSecret() on it, same pattern as commitmentKey's hex regex — Keypair.fromSecret() throws synchronously on a malformed secret, and since that call sits inside the Mppx.create() branch, an unvalidated bad key throws at import time and crashes chargeMppx along with it, not just Session", + "Passes store (e.g. Store.memory() for dev, a persistent store for production) into stellar.channel()'s config for Session mode, not just for Charge — channel.Parameters declares store as required, not optional, so omitting it throws at construction time inside the same ternary, which is exactly the failure this section's 'no intent's setup may throw' rule is meant to prevent" + ] +} diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 19e2433..a75f11b 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -138,13 +138,39 @@ import express from "express"; import { Mppx } from "mppx/express"; import { Store } from "mppx/server"; import * as stellar from "@stellar/mpp/channel/server"; +import { StrKey } from "@stellar/stellar-sdk"; + +const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +// commitmentKey must be a Stellar G... address (or a Keypair) — never raw +// ed25519 bytes. MPP_COMMITMENT_KEY is generated and stored as 64-char hex +// (see the testnet runbook), so re-encode it before handing it to the library. +// Validate the raw string, not the decoded length: Buffer.from(x, "hex") +// stops at the first invalid hex character instead of throwing, so 64 +// valid chars followed by garbage still decodes to exactly 32 bytes — a +// length check on the buffer can't catch that. StrKey.encodeEd25519PublicKey() +// doesn't check length either, so a bad value would otherwise silently +// become a plausible-looking wrong G... address instead of an error. +if (!/^[0-9a-f]{64}$/i.test(process.env.MPP_COMMITMENT_KEY)) { + throw new Error("MPP_COMMITMENT_KEY must be exactly 64 hex characters"); +} +const commitmentKey = StrKey.encodeEd25519PublicKey( + Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") +); const mppx = Mppx.create({ secretKey: process.env.MPP_SECRET_KEY, methods: [ stellar.channel({ - channel: process.env.CHANNEL_CONTRACT, // C... contract address - commitmentKey: process.env.COMMITMENT_PUBKEY, // 64-char hex ed25519 public key + channel: process.env.MPP_CHANNEL_CONTRACT, // C... contract address + commitmentKey, + // Both optional but strongly recommended: the channel contract is + // deployed out-of-band, so without these the library can't reject a + // channel that would settle to someone else's account or pay out in + // the wrong token — it only logs a startup warning and trusts the + // on-chain contract. + recipient: process.env.STELLAR_RECIPIENT, // reused from charge mode + currency: USDC_SAC_TESTNET, store: Store.memory(), // dev only — use persistent store in production network: "stellar:testnet", }), @@ -204,7 +230,7 @@ import { close } from "@stellar/mpp/channel/server"; import * as StellarSdk from "@stellar/stellar-sdk"; const txHash = await close({ - channel: process.env.CHANNEL_CONTRACT, + channel: process.env.MPP_CHANNEL_CONTRACT, amount: lastCumulativeAmount, // bigint, total USDC owed in base units signature: lastCommitmentSignature, // hex string from final commitment feePayer: { envelopeSigner: StellarSdk.Keypair.fromSecret(process.env.FEE_PAYER_SECRET) }, @@ -214,9 +240,278 @@ const txHash = await close({ console.log("Channel closed:", txHash); ``` -**Env vars (server):** `CHANNEL_CONTRACT`, `COMMITMENT_PUBKEY`, `MPP_SECRET_KEY`, `FEE_PAYER_SECRET` +**Env vars (server):** `MPP_CHANNEL_CONTRACT`, `MPP_COMMITMENT_KEY`, `STELLAR_RECIPIENT`, `MPP_SECRET_KEY`, `FEE_PAYER_SECRET` **Env vars (client):** `COMMITMENT_SECRET` +## Production patterns + +Three patterns for running Charge and Session behind the same server, +verified against a service that's been billing real USDC over MPP Charge +in production since before this skill existed. + +### Recipient resolution (recover at boot, fail closed per request) + +Two failure modes hit real deployments: `STELLAR_RECIPIENT` isn't set +yet (CI, a fresh environment before secrets are provisioned), or it's +set to the wrong value — a secret key (`S...`) pasted where the public +key belongs, which happens more than you'd expect when a platform's env +var UI doesn't visually distinguish the two. Neither should crash the +server at import time — but neither should let a paid route respond for +free once the server is up. Those are two separate failure surfaces: +booting and billing. + +```js +import { Keypair, StrKey } from "@stellar/stellar-sdk"; + +function resolveRecipient() { + let raw = (process.env.STELLAR_RECIPIENT || "").trim().replace(/['"]/g, ""); + if (!raw) return ""; + + if (raw.startsWith("S")) { + // A secret key was set where the public key belongs — recover instead + // of failing. Warn loudly; this should get fixed, not silently relied on. + try { + const pub = Keypair.fromSecret(raw).publicKey(); + console.warn(`STELLAR_RECIPIENT is a secret key — derived public key: ${pub.slice(0, 8)}...`); + return pub; + } catch { + console.error("STELLAR_RECIPIENT looks like a secret key but failed to parse — disabling MPP"); + return ""; + } + } + + // A typo'd G... (wrong length, bad checksum) used to reach the SDK + // unvalidated and throw several frames deep in Mppx.create(), away from + // the env var that actually caused it. Fail here instead, with context. + if (!StrKey.isValidEd25519PublicKey(raw)) { + console.error(`STELLAR_RECIPIENT is not a valid Stellar public key — disabling MPP: ${raw.slice(0, 8)}...`); + return ""; + } + + return raw; +} + +const RECIPIENT = resolveRecipient(); + +let chargeMppx = null; +if (RECIPIENT && process.env.MPP_SECRET_KEY) { + chargeMppx = Mppx.create({ /* ... */ }); +} else { + console.warn("MPP_SECRET_KEY missing, or STELLAR_RECIPIENT missing or invalid — MPP charge middleware disabled"); +} + +// Every route's middleware checks the instance, not the env var directly. +// Fail CLOSED here, not open: a rotated secret or a misconfigured deploy +// must never turn a paid route into a free one. `next()` would let the +// route respond with its normal 200 and no charge at all — indistinguishable +// from a bug, and silent. +export function mppChargeMiddleware(amount, description) { + return async (req, res, next) => { + if (!chargeMppx) { + res.setHeader("X-MPP-Warning", "MPP not configured on this server"); + res.status(503).json({ error: "MPP charge unavailable — payment middleware not initialized" }); + return; + } + // Delegate to the same per-route handler the standalone Charge server + // example above mounts directly (mppx.charge({ amount, description })), + // just called manually here instead of passed to app.get() — this + // factory's whole job is the null-check above it, not a different + // charge implementation. + await chargeMppx.charge({ amount, description })(req, res, next); + }; +} +``` + +The `S...`-key recovery is the case worth stealing even if you don't +need the rest: it turns a silent misconfiguration into a loud warning +plus an explicit `503`, instead of either a `Keypair.fromPublicKey` throw +three layers down in the SDK with no context about which env var caused +it, or — worse — a paid route quietly serving its content for free +because there was nothing left to charge against it. + +### Optional dual-intent server + +Charge and Session don't have to be an either/or choice at the code +level. Give each mode its own `Mppx` instance, initialize it only when +its full config is present, and let each intent's own middleware fail +closed — never throw, and never let the route respond for free — when +the instance for that intent is `null`. Charge's and +Session's server adapters both export their namespace as `stellar` (see +the Charge and Channel server imports above), so combining them in one +file means aliasing one — here Channel's becomes `stellarChannel`: + +```js +import { Mppx } from "mppx/express"; +import { Store } from "mppx/server"; +import * as stellar from "@stellar/mpp/charge/server"; +import * as stellarChannel from "@stellar/mpp/channel/server"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; + +const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +// RECIPIENT is the resolveRecipient() result from the pattern above. + +const chargeMppx = (RECIPIENT && process.env.MPP_SECRET_KEY) + ? Mppx.create({ methods: [stellar.charge({ recipient: RECIPIENT, /* ... */ })] }) + : null; + +// Same hex -> G-address re-encoding as the Session server example above, +// but this section's whole point is that no intent's setup may throw at +// import time or take another intent down with it — chargeMppx above must +// stay up even if MPP_COMMITMENT_KEY is garbage. So this catches instead +// of throwing: log it, leave commitmentKey undefined, and let the gate +// below fail Session closed (sessionMppx stays null) exactly the same way +// a genuinely-unset env var already does. Validates the raw string, not +// the decoded length — Buffer.from(x, "hex") stops at the first invalid +// character instead of throwing, so 64 valid chars followed by garbage +// still decodes to exactly 32 bytes. +let commitmentKey; +if (process.env.MPP_COMMITMENT_KEY) { + if (/^[0-9a-f]{64}$/i.test(process.env.MPP_COMMITMENT_KEY)) { + commitmentKey = StrKey.encodeEd25519PublicKey( + Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") + ); + } else { + console.error("MPP_COMMITMENT_KEY is not 64 hex characters — Session mode disabled, Charge unaffected"); + } +} + +// Same pattern as commitmentKey above: validate the raw string before +// handing it to Keypair.fromSecret(), which throws on a malformed secret +// key. Unvalidated, that throw happens inside the Mppx.create() call +// below — at import time, taking chargeMppx down with it. Unlike Charge +// (where feePayer is genuinely optional — see the ternary in the Charge +// server example above), Session's own env var list two sections up +// already lists FEE_PAYER_SECRET without "(optional)", and +// channel.Parameters' own doc comment says feePayer is "Required when +// handling close credential actions" — so this validates it, rather than +// treating it as optional the way Charge's ternary does. +let feePayerSigner; +if (process.env.FEE_PAYER_SECRET) { + if (StrKey.isValidEd25519SecretSeed(process.env.FEE_PAYER_SECRET)) { + feePayerSigner = Keypair.fromSecret(process.env.FEE_PAYER_SECRET); + } else { + console.error("FEE_PAYER_SECRET is not a valid Stellar secret key — Session mode disabled, Charge unaffected"); + } +} + +// Same validate-before-use pattern as commitmentKey and feePayerSigner +// above: channel() only validates store, not the channel address itself +// — a typo'd C... value still builds sessionMppx, /info still reports +// Session enabled, and the address only reaches `new Contract(...)` +// deep inside the SDK on the first paid request, where it throws +// "Invalid contract ID" instead of failing at boot the way this whole +// section exists to guarantee. +let channelAddress; +if (process.env.MPP_CHANNEL_CONTRACT) { + if (StrKey.isValidContract(process.env.MPP_CHANNEL_CONTRACT)) { + channelAddress = process.env.MPP_CHANNEL_CONTRACT; + } else { + console.error("MPP_CHANNEL_CONTRACT is not a valid Stellar contract ID — Session mode disabled, Charge unaffected"); + } +} + +const sessionMppx = ( + channelAddress && + commitmentKey && + RECIPIENT && + process.env.MPP_SECRET_KEY && + feePayerSigner +) + ? Mppx.create({ + methods: [ + stellarChannel.channel({ + channel: channelAddress, + commitmentKey, + // Strongly recommended, not just optional: RECIPIENT is already a + // precondition to reach this branch, so wire it through instead of + // leaving the channel's payout address unverified against it. + recipient: RECIPIENT, + currency: USDC_SAC_TESTNET, + // Required, not optional, in channel.Parameters — same as Charge + // mode's own store above, already shown in full in the standalone + // Charge and Session server examples earlier in this file. + // Omitting it here throws at construction time (inside this same + // ternary), which is exactly the "no intent's setup may throw" + // rule this section exists to enforce. + store: Store.memory(), // dev only — use a persistent store in production + feePayer: { envelopeSigner: feePayerSigner }, + /* ... */ + }), + ], + }) + : null; + +export const isSessionEnabled = () => !!sessionMppx; + +// Same shape as mppChargeMiddleware above, and for the same reason: a +// route wired up the way the standalone Session server example higher in +// this file shows — sessionMppx.channel({ amount, description }) called +// directly as route middleware — evaluates that call at route +// registration time, which runs at import time. With sessionMppx `null` +// (Session not configured), that throws immediately and takes the whole +// process down, chargeMppx included — exactly what this section's "no +// intent's setup may throw or take another down with it" rule exists to +// prevent. Route through this factory instead of calling sessionMppx +// directly. +export function mppSessionMiddleware(amount, description) { + return async (req, res, next) => { + if (!sessionMppx) { + res.setHeader("X-MPP-Warning", "MPP Session not configured on this server"); + res.status(503).json({ error: "MPP session unavailable — payment middleware not initialized" }); + return; + } + // Same delegation as mppChargeMiddleware above, to the Channel + // equivalent of the standalone server example's mppx.channel({...}). + await sessionMppx.channel({ amount, description })(req, res, next); + }; +} +``` + +This is the pattern actually running in production: Charge mode is +initialized and billing; Session mode's instance is `null` there today, +by choice — it requires deploying and funding a channel contract per +deployment, a step that carries custody implications worth a compliance +pass before turning on for a given business. Session works the same way +Charge does once its five env vars are set; nothing in the server code +changes when you flip it on later. What this pattern buys you is +shipping Charge on day one without a rewrite pending. + +### Runtime configuration-status endpoint (`/info`) + +Not the OpenAPI discovery document below, and not a health check either — +call it that and a caller will expect it to confirm the Soroban RPC, the +facilitator, and the store backend it depends on are actually reachable +right now. It doesn't: it only reports whether each intent's `Mppx` +instance was constructed at startup, which is configuration state, not +live dependency health. A client (human or agent) shouldn't have to guess +which intents are live. Report the true initialization state, not a +static capability list — `enabled` reflects whether the instance actually +initialized: + +```js +app.get("/info", (_req, res) => { + res.json({ + protocol: "mpp", + intents: { + charge: { + enabled: !!chargeMppx, + routes: { data: { path: "/data", price: "0.001 USDC" } }, + }, + session: { + enabled: isSessionEnabled(), + channelContract: process.env.MPP_CHANNEL_CONTRACT || null, + note: "Off-chain cumulative commitments, two on-chain txs total (deposit + close).", + }, + }, + }); +}); +``` + +An agent that reads this before its first request can pick a working +intent instead of finding out from a 503 that Session was never +configured. + ## Discovery: let agents find your paid API Charge and Session modes answer one question: how do I charge? Discovery answers a second: how does a paying agent find me? Without discovery you ship a working paid API that no agent can locate. @@ -301,11 +596,21 @@ npm install @stellar/mpp mppx @stellar/stellar-sdk **Session mode only:** 4. Deploy the one-way-channel contract (see [stellar-mpp-sdk](https://github.com/stellar/stellar-mpp-sdk) for deploy script) -5. Generate a 64-char hex ed25519 seed for the commitment key: +5. Generate a 64-char hex ed25519 seed for the commitment key — this value + is `COMMITMENT_SECRET`, kept on the **client** only: ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` -6. Derive the public key and fund the channel with USDC before making requests +6. Derive the corresponding public key from that same seed — this is + `MPP_COMMITMENT_KEY`, what the **server** holds, stored as hex the same + way (the server code above re-encodes it to a G... address at startup). + Export the seed from step 5 first, then read it from the environment + rather than passing it as a bare CLI argument: + ```bash + export COMMITMENT_SECRET="<64-char hex from step 5>" + node -e "const {Keypair}=require('@stellar/stellar-sdk');const seed=Buffer.from(process.env.COMMITMENT_SECRET,'hex');console.log(Keypair.fromRawEd25519Seed(seed).rawPublicKey().toString('hex'))" + ``` + Then fund the channel with USDC before making requests. ## Common pitfalls diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 63805b9..2012c1c 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -27,7 +27,7 @@ The key Stellar difference: clients sign **auth entries**, not full transaction ## Seller: monetize an Express API ```bash -npm install @x402/express @x402/core @x402/stellar express dotenv +npm install @x402/express @x402/core @x402/stellar @stellar/stellar-sdk express dotenv npm pkg set type=module ``` @@ -50,7 +50,7 @@ if (!process.env.OZ_API_KEY) { } const facilitator = new HTTPFacilitatorClient({ - url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet", + url: process.env.FACILITATOR_URL || "https://channels.openzeppelin.com/x402/testnet", // OZ Channels requires Bearer auth on both testnet and mainnet createAuthHeaders: async () => { const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` }; @@ -99,6 +99,168 @@ app.listen(3001, () => console.log(`x402 server on http://localhost:3001 (${NETW **`payTo` is the recipient's classic Stellar account (`G...`), not the USDC SAC contract address.** Sending USDC lands in the classic balance of the `payTo` account, which is why that account also needs a USDC trustline. The SAC contract address is what the protocol invokes `transfer` on; see [Two USDC addresses](SKILL.md#two-usdc-addresses-dont-confuse-them) in the router. +## Pricing multiple routes with `paymentMiddlewareFromConfig` + +The seller example above prices a single route through `paymentMiddleware` +and `x402ResourceServer`. For more than one paid route, `@x402/express` also +exports `paymentMiddlewareFromConfig`, which takes the same route-keyed +object directly — no `x402ResourceServer` wrapper, but you assemble the +facilitator client and scheme registration yourself: + +```js +import { paymentMiddlewareFromConfig } from "@x402/express"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { ExactStellarScheme } from "@x402/stellar/exact/server"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; + +const facilitator = new HTTPFacilitatorClient({ + // @x402/core falls back to its own DEFAULT_FACILITATOR_URL + // ("https://x402.org/facilitator") when this is unset. That facilitator + // does support stellar:testnet, so the failure isn't "wrong chain" — it's + // two others: createAuthHeaders below sends the OZ_API_KEY Bearer token + // to whichever URL ends up here, so an unset env var silently leaks that + // credential to an unrelated operator; and x402.org has no stellar:pubnet + // entry at all, so the exact same unset-env-var mistake in a mainnet + // deployment fails differently and more confusingly than in testnet. Set + // the default explicitly instead of relying on that silent fallback. + url: process.env.FACILITATOR_URL || "https://channels.openzeppelin.com/x402/testnet", + createAuthHeaders: async () => { + const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` }; + return { verify: h, settle: h, supported: h }; + }, +}); + +// Same resolveRecipient() as mpp.md's Recipient resolution pattern, not +// just a cross-reference to it in prose — payTo below lands unvalidated +// in the 402 response body for every unauthenticated caller to see, and +// an unrejected malformed value would either leak a pasted secret key +// there or get handed to paymentMiddlewareFromConfig() instead of +// tripping the fail-closed fallback further down. +function resolveRecipient() { + let raw = (process.env.STELLAR_RECIPIENT || "").trim().replace(/['"]/g, ""); + if (!raw) return ""; + + if (raw.startsWith("S")) { + try { + const pub = Keypair.fromSecret(raw).publicKey(); + console.warn(`STELLAR_RECIPIENT is a secret key — derived public key: ${pub.slice(0, 8)}...`); + return pub; + } catch { + console.error("STELLAR_RECIPIENT looks like a secret key but failed to parse — disabling x402"); + return ""; + } + } + + if (!StrKey.isValidEd25519PublicKey(raw)) { + console.error(`STELLAR_RECIPIENT is not a valid Stellar public key — disabling x402: ${raw.slice(0, 8)}...`); + return ""; + } + + return raw; +} + +const PAY_TO = resolveRecipient(); + +const PAID_ROUTES = { + "GET /signals": { + accepts: { scheme: "exact", price: "$0.02", network: NETWORK, payTo: PAY_TO }, + description: "Live market signals", + }, + "GET /market": { + accepts: { scheme: "exact", price: "$0.05", network: NETWORK, payTo: PAY_TO }, + description: "Enriched market state", + }, + "POST /execute": { + accepts: { scheme: "exact", price: "$0.25", network: NETWORK, payTo: PAY_TO }, + description: "Run a strategy", + }, +}; + +// Fails closed, not open: initialize the real middleware only when PAY_TO +// is set. Same rule as mpp.md's charge/channel middleware: a paid +// production path degrades loudly, never silently. +// +// Don't try to replicate @x402/core's own route matching here to decide +// which unconfigured requests deserve a 503. getRouteConfig() uppercases +// the method and normalizes the path — ordinary percent-encoding gets +// decoded (GET /%73ignals matches the same route as GET /signals), +// collapses duplicate slashes, and strips a trailing slash — before +// matching case-insensitively against each route's compiled regex. (A +// literal %2f or %5c is deliberately left encoded rather than decoded +// into an actual slash or backslash, so it can never be mistaken for a +// real path separator — the opposite of the ordinary-character case.) +// Beyond literal paths like the ones above, a key can also be a wildcard +// (*), a named parameter (:city), or a bracket parameter ([id]), each +// compiled to its own regex at startup. A hand-written check that tries +// to mirror all of that is exactly the kind of security-relevant logic +// that's easy to get subtly wrong (a case or encoding variant slips +// through free) and hard to keep in sync as routes change. Mount this +// middleware under its own path instead, and make the unconfigured +// fallback unconditional: every request that reaches it is, by +// construction, part of the paid API. +let x402Middleware; +if (PAY_TO) { + x402Middleware = paymentMiddlewareFromConfig( + PAID_ROUTES, + facilitator, + [{ network: NETWORK, server: new ExactStellarScheme() }], + { appName: "My API", testnet: NETWORK === "stellar:testnet" }, + ); +} else { + console.warn("STELLAR_RECIPIENT missing or invalid — paid routes return 503 instead of pricing"); + x402Middleware = (req, res) => { + res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT missing or invalid" }); + }; +} + +// Mounted under /paid rather than at the app root, so the unconfigured +// fallback above never has to decide which requests it applies to — it's +// unconditional 503 for everything reaching it, configured or not. That +// "every request under /paid is paid" framing holds only while every +// handler you register under this prefix has a matching key in +// PAID_ROUTES; a handler added under /paid without one still falls +// through to next() unpriced once PAY_TO *is* configured (unchanged +// library behavior, see below) — it's just never free by accident during +// the specific failure this pattern guards against. +app.use("/paid", x402Middleware); + +// Route handlers move under /paid too — this is the part that actually +// matters. A handler left registered at the old top-level app.get("/signals", ...) +// never passes through x402Middleware at all and serves its content free, +// with or without PAY_TO configured. Copying only the middleware mount +// above without moving the handlers reintroduces the exact bug this +// pattern exists to close. +app.get("/paid/signals", (req, res) => { + res.json({ result: "live market signals" }); +}); +app.get("/paid/market", (req, res) => { + res.json({ result: "enriched market state" }); +}); +app.post("/paid/execute", (req, res) => { + res.json({ result: "strategy executed" }); +}); +``` + +Each key is `"METHOD /path"`, relative to the `/paid` mount point above +(so `"GET /signals"` answers `GET /paid/signals`); a request under `/paid` +that doesn't match any key still falls through to `next()` unpriced when +`PAY_TO` is set — that part is unchanged. + +**Recover at boot, fail closed per request, when the recipient isn't +configured.** A server that throws at boot because `STELLAR_RECIPIENT` is +unset breaks CI and any environment that hasn't provisioned secrets yet — +that part should never throw. But once the server is up, a paid route +must never respond with its normal, unpriced 200 just because payment +enforcement couldn't be set up; that's a silent free tier no one decided +to offer, which is what the `if (PAY_TO)` guard above does: mount the +payment gate under its own path prefix, and respond `503` unconditionally +for everything under it when unconfigured, rather than trying to decide +per-request which paths would have been charged. See +[Recipient resolution](mpp.md#recipient-resolution-recover-at-boot-fail-closed-per-request) +in mpp.md for the fuller pattern this borrows from — recovering a secret +key pasted in the wrong env var, and rejecting a malformed `G...` instead +of letting it throw deep in the SDK. + ## Buyer: agent client ```bash