-
Notifications
You must be signed in to change notification settings - Fork 17
docs: add production patterns for x402 and MPP #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(); | ||||||||||
| } | ||||||||||
|
Comment on lines
+339
to
+342
|
||||||||||
| if (!chargeMppx) { | |
| res.setHeader("X-MPP-Warning", "MPP not configured"); | |
| return next(); | |
| } |
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling next() after nodeHandler(req, res) purely based on statusCode can cause double-handling if the MPP handler already wrote/ended the response (common symptom: “Cannot set headers after they are sent”). Prefer guarding with if (!res.headersSent && !res.writableEnded) (or equivalent) before calling next(), and keep the decision based on whether the handler produced a 402 challenge vs a pass-through.
| if (res.statusCode !== 402) next(); | |
| const isPaymentChallenge = res.statusCode === 402; | |
| if (isPaymentChallenge) return; | |
| if (!res.headersSent && !res.writableEnded) return next(); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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")); | ||||||||||||||||||||||||||||||
|
|
@@ -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", | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet", | |
| url: | |
| process.env.FACILITATOR_URL ?? | |
| (network === "stellar:pubnet" | |
| ? "https://channels.openzeppelin.com/x402" | |
| : "https://channels.openzeppelin.com/x402/testnet"), |
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This “graceful degradation” pattern can unintentionally disable payments in production if STELLAR_RECIPIENT is missing/misnamed, opening premium routes without auth/payment. Recommend requiring an explicit opt-in flag for the passthrough behavior (e.g., ALLOW_UNPAID_PREMIUM=true) and/or restricting passthrough to non-production environments to reduce the risk of accidentally running unpaid.
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The text says the signed proof is in the X-PAYMENT header, but the example logs x-payment-from and never references X-PAYMENT. To avoid confusion, either update the prose to match the headers actually used, or update the example to log (or hash/store) the X-PAYMENT proof alongside the payer identity.
| app.get("/api/premium/signals", (req, res) => { | |
| // Non-blocking payment log | |
| logPayment({ | |
| from: req.headers["x-payment-from"] || "unknown", | |
| app.get("/api/premium/signals", (req, res) => { | |
| const paymentProof = req.headers["x-payment"]; | |
| const paymentProofHash = paymentProof | |
| ? require("crypto").createHash("sha256").update(paymentProof).digest("hex") | |
| : null; | |
| // Non-blocking payment log | |
| logPayment({ | |
| from: req.headers["x-payment-from"] || "unknown", | |
| paymentProofHash, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This appears under “Production patterns” but uses
Store.memory(), which is explicitly unsafe in production (you already note that above). To prevent copy/paste footguns, consider swapping the example to a persistent store placeholder (or a minimal example of one), and keepStore.memory()only in clearly labeled dev/test snippets.