From 930abdddf8f1ca3f95bf889b483f2a580ea7f5ee Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 14 Aug 2026 19:51:35 -0600 Subject: [PATCH 1/2] docs(agentic-payments): production patterns for x402 + MPP Adds three patterns verified against a service that has been billing real USDC over MPP Charge and x402 in production: - Multi-route pricing with paymentMiddlewareFromConfig (x402.md) - Recipient resolution that fails open instead of crashing on missing or misconfigured STELLAR_RECIPIENT, including recovery when a secret key lands in the public-key env var (mpp.md) - Optional dual-intent server: Charge and Session gated independently by their own env vars, each middleware no-op'ing rather than throwing when its intent isn't configured (mpp.md) - A runtime-accurate /info discovery endpoint reporting which intents are actually live, not a static capability list (mpp.md) Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/mpp.md | 127 ++++++++++++++++++++++++++++++++ skills/agentic-payments/x402.md | 68 +++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 19e2433..8e829c8 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -217,6 +217,133 @@ 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. + +### Runtime status endpoint (`/info`) + +Not the OpenAPI discovery document below — this is a lighter, unauthenticated +health check specific to this server's own deployment. 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. + ## 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. 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 From 16c2d922aea2fbc51066c2fccedf5ca65b13df0a Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sun, 16 Aug 2026 15:33:49 -0600 Subject: [PATCH 2/2] docs(agentic-payments): address Copilot review on #97 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - x402.md: drop a stray "+" line-join artifact in the multi-route pricing intro. - mpp.md: align the pre-existing server env var names to what the service actually reads (MPP_CHANNEL_CONTRACT / MPP_COMMITMENT_KEY, verified against packages/agent/src/middleware/mpp.ts) instead of weakening the new examples to match the wrong CHANNEL_CONTRACT / COMMITMENT_PUBKEY names already in the doc. - mpp.md: add the missing imports to the dual-intent snippet, and note why Channel's server adapter needs an alias (`stellarChannel`) — both it and Charge's export their namespace as `stellar`. Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/mpp.md | 19 ++++++++++++++----- skills/agentic-payments/x402.md | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 8e829c8..b5d3545 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -143,8 +143,8 @@ 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: process.env.MPP_COMMITMENT_KEY, // 64-char hex ed25519 public key store: Store.memory(), // dev only — use persistent store in production network: "stellar:testnet", }), @@ -204,7 +204,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,7 +214,7 @@ 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`, `MPP_SECRET_KEY`, `FEE_PAYER_SECRET` **Env vars (client):** `COMMITMENT_SECRET` ## Production patterns @@ -285,9 +285,18 @@ layers down in the SDK with no context about which env var caused it. 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`: +throw — 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 * as stellar from "@stellar/mpp/charge/server"; +import * as stellarChannel from "@stellar/mpp/channel/server"; + +// 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; diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 2f463fa..fa7d38d 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -101,8 +101,8 @@ app.listen(3001, () => console.log(`x402 server on http://localhost:3001 (${NETW ## 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 +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: