diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 9faec24..7fa9266 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -217,6 +217,132 @@ console.log("Channel closed:", txHash); **Env vars (server):** `CHANNEL_CONTRACT`, `COMMITMENT_PUBKEY`, `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 (fail open, not crash) + +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. + +```js +import { Keypair } 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 ""; + } + } + return raw; +} + +const RECIPIENT = resolveRecipient(); + +let chargeMppx = null; +if (RECIPIENT && process.env.MPP_SECRET_KEY) { + chargeMppx = Mppx.create({ /* ... */ }); +} else { + console.warn("MPP_SECRET_KEY or STELLAR_RECIPIENT not set — MPP charge middleware disabled"); +} + +// Every route's middleware checks the instance, not the env var directly: +export function mppChargeMiddleware(amount, description) { + return async (req, res, next) => { + if (!chargeMppx) { + res.setHeader("X-MPP-Warning", "MPP not configured on this server"); + return next(); // route still responds — unpriced, not broken + } + // ... normal charge flow + }; +} +``` + +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 a working server, instead of a `Keypair.fromPublicKey` throw three +layers down in the SDK with no context about which env var caused 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 route middleware no-op — not +throw — when the instance for that intent is `null`: + +```js +const chargeMppx = (RECIPIENT && process.env.MPP_SECRET_KEY) + ? Mppx.create({ methods: [stellar.charge({ recipient: RECIPIENT, /* ... */ })] }) + : null; + +const sessionMppx = ( + process.env.MPP_CHANNEL_CONTRACT && + process.env.MPP_COMMITMENT_KEY && + RECIPIENT && + process.env.MPP_SECRET_KEY +) + ? Mppx.create({ methods: [stellarChannel.channel({ channel: process.env.MPP_CHANNEL_CONTRACT, /* ... */ })] }) + : null; + +export const isSessionEnabled = () => !!sessionMppx; +``` + +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 four 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. + +### Discovery endpoint (`/info`) + +A client (human or agent) shouldn't have to guess which intents are +live. Report the true runtime state, not a static capability list — +`enabled` reflects whether the instance actually initialized, which is +also a live health check: + +```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 500 that Session was never +configured. + ## Packages and subpath imports ```bash diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 63805b9..2f463fa 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -99,6 +99,74 @@ 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` + +`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"; + +const facilitator = new HTTPFacilitatorClient({ + url: process.env.FACILITATOR_URL, + createAuthHeaders: async () => { + const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` }; + return { verify: h, settle: h, supported: h }; + }, +}); + +const PAID_ROUTES = { + "GET /signals": { + accepts: { + scheme: "exact", price: "$0.02", network: NETWORK, + payTo: process.env.STELLAR_RECIPIENT, + description: "Live market signals", + }, + }, + "GET /market": { + accepts: { + scheme: "exact", price: "$0.05", network: NETWORK, + payTo: process.env.STELLAR_RECIPIENT, + description: "Enriched market state", + }, + }, + "POST /execute": { + accepts: { + scheme: "exact", price: "$0.25", network: NETWORK, + payTo: process.env.STELLAR_RECIPIENT, + description: "Run a strategy", + }, + }, +}; + +app.use( + paymentMiddlewareFromConfig( + PAID_ROUTES, + facilitator, + [{ network: NETWORK, server: new ExactStellarScheme() }], + { appName: "My API", testnet: NETWORK === "stellar:testnet" }, + ), +); +``` + +Each key is `"METHOD /path"`; a request that doesn't match any key falls +through to `next()` unpriced. This is what's running behind Nirium's own +mainnet endpoint (three routes, three prices) — first settlement, +verifiable on Stellar Expert: [`3134a51c…7558bc`](https://stellar.expert/explorer/public/tx/3134a51c66091fd7fbd85b38a4a6ec6cd432bb92c2450eac84ea7855cb7558bc). + +**Fail open, not crash, 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. See +[Recipient resolution](mpp.md#recipient-resolution-fail-open-not-crash) +in mpp.md for the fuller pattern — it applies here too: wrap middleware +initialization in a check, and fall through to `next()` when the +recipient is missing instead of throwing. + ## Buyer: agent client ```bash