From df8f5f3c35f42b584447586a62c3b9c1071a577c Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 10 Apr 2026 18:44:47 -0600 Subject: [PATCH 1/2] docs: add production patterns for x402 and MPP --- README.md | 4 ++ skill/mpp.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ skill/x402.md | 91 +++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) diff --git a/README.md b/README.md index 2a706dc..be124f9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ skill/ ├── frontend-stellar-sdk.md # Frontend integration patterns ├── testing.md # Testing strategies ├── stellar-assets.md # Asset issuance and management +├── x402.md # Pay-per-request payments (Coinbase) +├── mpp.md # Machine Payments Protocol (Stellar/Stripe) ├── zk-proofs.md # ZK proof architecture and verification patterns ├── api-rpc-horizon.md # API access (RPC/Horizon) ├── security.md # Security checklist @@ -84,6 +86,8 @@ The skill uses a progressive disclosure pattern. The main `SKILL.md` provides co "How do I deploy a contract to Stellar Testnet?" "Create unit tests for my Soroban contract" "Review this contract for security issues" +"Build a paid API using x402 on Stellar" +"Set up MPP charge and channel modes for agent payments" ``` ## Contributing diff --git a/skill/mpp.md b/skill/mpp.md index 6ccd66d..d9b4fe6 100644 --- a/skill/mpp.md +++ b/skill/mpp.md @@ -252,3 +252,133 @@ npm install @stellar/mpp mppx @stellar/stellar-sdk **`Store.memory()` in production** - Symptom: server loses track of channel state on restart, enables double-spend - Fix: replace `Store.memory()` with a persistent store (database-backed) before going to production. + +## Production patterns + +### Combining charge + channel in the same server + +In production, expose both intents on different route prefixes. Clients choose based on their usage pattern — occasional callers use charge, high-frequency agents use channel: + +```js +// dual-mode-server.js +import express from "express"; +import { Mppx, Store } from "mppx"; +import * as chargeServer from "@stellar/mpp/charge/server"; +import * as channelServer from "@stellar/mpp/channel/server"; +import * as StellarSdk from "@stellar/stellar-sdk"; + +const NETWORK = "stellar:testnet"; +const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +const RECIPIENT = process.env.STELLAR_RECIPIENT; + +// Charge mode — per-request payments +const chargeMppx = Mppx.create({ + secretKey: process.env.MPP_SECRET_KEY, + methods: [ + chargeServer.stellar.charge({ + recipient: RECIPIENT, + currency: USDC_SAC, + network: NETWORK, + }), + ], +}); + +// Channel mode — off-chain cumulative payments +const channelMppx = process.env.CHANNEL_CONTRACT + ? Mppx.create({ + secretKey: process.env.MPP_SECRET_KEY, + methods: [ + channelServer.stellar.channel({ + channel: process.env.CHANNEL_CONTRACT, + commitmentKey: process.env.COMMITMENT_PUBKEY, + network: NETWORK, + store: Store.memory(), // use persistent store in production + }), + ], + }) + : null; + +const app = express(); +app.use(express.json()); + +// Charge routes +app.use("/api/charge", chargeMppx.middleware()); +app.get("/api/charge/data", (_req, res) => res.json({ mode: "charge" })); + +// Channel routes (graceful degradation if not configured) +if (channelMppx) { + app.use("/api/channel", channelMppx.middleware()); + app.get("/api/channel/data", (_req, res) => res.json({ mode: "channel" })); +} + +// Discovery endpoint — clients check which intents are available +app.get("/api/info", (_req, res) => { + res.json({ + protocol: "mpp", + intents: { + charge: { enabled: true, routes: { data: "/api/charge/data" } }, + channel: { + enabled: !!channelMppx, + contract: process.env.CHANNEL_CONTRACT || null, + routes: channelMppx ? { data: "/api/channel/data" } : {}, + }, + }, + }); +}); + +app.listen(3002); +``` + +### Per-route pricing with middleware factories + +Instead of global middleware, create per-route middleware functions for different price tiers: + +```js +function chargeMiddleware(amount, description) { + return async (req, res, next) => { + if (!chargeMppx) { + res.setHeader("X-MPP-Warning", "MPP not configured"); + return next(); + } + try { + const handler = chargeMppx.charge({ amount, description }); + // Use Mppx.toNodeListener to bridge Web API ↔ Express + const nodeHandler = Mppx.toNodeListener(handler); + await nodeHandler(req, res); + if (res.statusCode !== 402) next(); + } catch (err) { + res.status(500).json({ error: "Payment processing error" }); + } + }; +} + +// Different prices per endpoint +app.get("/signals", chargeMiddleware("0.01", "Market signals"), signalsHandler); +app.get("/market", chargeMiddleware("0.01", "Market data"), marketHandler); +app.post("/execute", chargeMiddleware("0.05", "Strategy execution"), executeHandler); +``` + +### Recipient key auto-resolution + +In containerized deployments (Railway, Docker), environment variables sometimes contain the secret key instead of the public key. Detect and derive automatically: + +```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")) { + try { + const pub = Keypair.fromSecret(raw).publicKey(); + console.warn(`[MPP] Derived public key from secret: ${pub.slice(0, 8)}...`); + return pub; + } catch { + console.error("[MPP] STELLAR_RECIPIENT looks like a secret but failed to parse"); + return ""; + } + } + return raw; +} +``` diff --git a/skill/x402.md b/skill/x402.md index 0ae3492..60e4d0e 100644 --- a/skill/x402.md +++ b/skill/x402.md @@ -201,3 +201,94 @@ Always test on testnet first. Switch by changing `network` and `FACILITATOR_URL` **OZ Channels 401 on mainnet** - Symptom: facilitator rejects with 401 - Fix: mainnet requires an API key in the `Authorization: Bearer` header — generate one at channels.openzeppelin.com/gen + +## Production patterns + +### Multi-route price table + +Use `paymentMiddlewareFromConfig` with a route map to set per-endpoint pricing. Paths are relative to the Express mount point — if you mount via `app.use('/api/premium', middleware)`, use `GET /signals` not `GET /api/premium/signals`. + +```js +// production-server.js +import express from "express"; +import { paymentMiddlewareFromConfig } from "@x402/express"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { ExactStellarScheme } from "@x402/stellar/exact/server"; + +const app = express(); +const network = process.env.STELLAR_NETWORK === "mainnet" ? "stellar:pubnet" : "stellar:testnet"; +const payTo = process.env.STELLAR_RECIPIENT; + +const facilitator = new HTTPFacilitatorClient({ + url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet", +}); + +const routes = { + "GET /signals": { + accepts: { scheme: "exact", price: "$0.01", network, payTo, description: "Market signals" }, + }, + "GET /market": { + accepts: { scheme: "exact", price: "$0.01", network, payTo, description: "Market state" }, + }, + "POST /execute": { + accepts: { scheme: "exact", price: "$0.05", network, payTo, description: "Strategy execution" }, + }, +}; + +const schemes = [{ network, server: new ExactStellarScheme() }]; + +app.use( + "/api/premium", + paymentMiddlewareFromConfig(routes, facilitator, schemes, { + appName: "My API", + testnet: network === "stellar:testnet", + }) +); + +app.get("/api/premium/signals", (_req, res) => res.json({ signals: [] })); +app.get("/api/premium/market", (_req, res) => res.json({ market: {} })); +app.post("/api/premium/execute", (_req, res) => res.json({ result: {} })); + +app.listen(3001); +``` + +### Graceful degradation + +In production, wrap initialization in a try/catch and fall back to a pass-through middleware when `payTo` is not configured. This prevents crashes during development or CI: + +```js +let x402Middleware; + +if (!process.env.STELLAR_RECIPIENT) { + console.warn("[x402] STELLAR_RECIPIENT not set — premium routes open (no payment required)"); + x402Middleware = (_req, _res, next) => next(); +} else { + try { + x402Middleware = paymentMiddlewareFromConfig(routes, facilitator, schemes); + console.log(`[x402] Payment middleware active | network: ${network}`); + } catch (err) { + console.error("[x402] Failed to initialize:", err); + x402Middleware = (_req, _res, next) => next(); + } +} + +app.use("/api/premium", x402Middleware); +``` + +### Payment receipt logging + +Log payment events after the middleware passes (HTTP 200). The `X-PAYMENT` header contains the signed payment proof. Log asynchronously to avoid blocking the response: + +```js +app.get("/api/premium/signals", (req, res) => { + // Non-blocking payment log + logPayment({ + from: req.headers["x-payment-from"] || "unknown", + route: req.path, + amount: "0.01", + timestamp: new Date().toISOString(), + }).catch(() => {}); // never fail a request due to logging + + res.json({ signals: [] }); +}); +``` From fc30027da0429934ea1fd858834907d5fa4e0bd0 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 2 May 2026 00:12:03 -0600 Subject: [PATCH 2/2] chore: sync institutional branding in skill docs --- skill/x402.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/x402.md b/skill/x402.md index 60e4d0e..38f4ee2 100644 --- a/skill/x402.md +++ b/skill/x402.md @@ -65,7 +65,7 @@ app.use( ); app.get("/weather", (_req, res) => { - res.json({ city: "San Francisco", temp: 18, conditions: "Foggy" }); + res.json({ city: "San Partner PM", temp: 18, conditions: "Foggy" }); }); app.listen(3001, () => console.log("x402 server on http://localhost:3001"));