From 930abdddf8f1ca3f95bf889b483f2a580ea7f5ee Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 14 Aug 2026 19:51:35 -0600 Subject: [PATCH 01/19] 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 02/19] 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: From 0174883ea51464f88e6bbf3191602bf9714c55c1 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 08:11:51 -0600 Subject: [PATCH 03/19] fix(agentic-payments): move description out of accepts in the multi-route example Real bug in the "Pricing multiple routes with paymentMiddlewareFromConfig" example: description was nested inside accepts for all three routes (GET /signals, GET /market, POST /execute), contradicting the single-route GET /weather example just above it in the same file, where description is already a sibling of accepts. Verified against the real installed @x402/core@2.22.0 types before fixing, not assumed from the existing example alone: PaymentOption (what accepts holds) declares scheme/payTo/price/network/ maxTimeoutSeconds/extra only, no description field at all. RouteConfig itself declares description as a top-level, sibling field. A description nested inside accepts is silently never read by the SDK. Moved all three description fields one level out to match the real type and the existing correct example. Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/x402.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index fa7d38d..e80cbff 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -125,22 +125,22 @@ const PAID_ROUTES = { accepts: { scheme: "exact", price: "$0.02", network: NETWORK, payTo: process.env.STELLAR_RECIPIENT, - description: "Live market signals", }, + description: "Live market signals", }, "GET /market": { accepts: { scheme: "exact", price: "$0.05", network: NETWORK, payTo: process.env.STELLAR_RECIPIENT, - description: "Enriched market state", }, + description: "Enriched market state", }, "POST /execute": { accepts: { scheme: "exact", price: "$0.25", network: NETWORK, payTo: process.env.STELLAR_RECIPIENT, - description: "Run a strategy", }, + description: "Run a strategy", }, }; From 23c798808a48487d548d8204abdf0dd610edc904 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 09:10:15 -0600 Subject: [PATCH 04/19] fix(agentic-payments): fail closed, not open, in the MPP production patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's review on #97 found what turned out to be a real bug, not a docs nit: the production-pattern examples in mpp.md taught fail-OPEN per-request behavior (next() when the payment instance is null), which lets a misconfigured or later-rotated deployment serve a paid route's content for free with only a warning header most clients never read. Verified against packages/agent/src/middleware/mpp.ts (the real service this doc says it's drawn from) before touching anything here: the real code had the identical bug, now fixed there to fail closed (503). This commit brings the doc back in sync with that fix, and separates two failure surfaces the old single heading conflated: - Booting: never throw at import time on a missing/misconfigured STELLAR_RECIPIENT — unchanged, this part was already correct. - Billing: once the server is up, a null payment instance must return a non-200 (503) per request, never fall through to the route's normal, unpriced 200. Renamed the two affected headings accordingly and updated the fail-open prose in both mpp.md sections plus the /500/503 reference in the /info paragraph. Fixed the now-stale anchor link from x402.md's own 'Fail open, not crash' cross-reference, which still correctly describes x402.ts's actual current behavior (unchanged in this PR — flagging that gap for a separate fix, out of scope here) and only needed its link target updated. Also renamed /info from 'health check' to 'configuration-status endpoint' — Copilot correctly pointed out it only reports whether each Mppx instance was constructed at startup, not whether the RPC, facilitator, or store backend it depends on are actually reachable right now. Calling that a health check overpromises. Adds the two evals/ scenarios this change was missing per README.md's own contribution rule ('update or add the matching scenario under evals/ in the same PR'): 04-multi-route-pricing.json for paymentMiddlewareFromConfig, and 05-production-hardening.json for the three mpp.md patterns, both asserting the fail-closed behavior above. Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 15 +++++++ .../05-production-hardening.json | 12 +++++ skills/agentic-payments/mpp.md | 44 ++++++++++++------- skills/agentic-payments/x402.md | 2 +- 4 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 evals/scenarios/agentic-payments/04-multi-route-pricing.json create mode 100644 evals/scenarios/agentic-payments/05-production-hardening.json 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..5e07ce8 --- /dev/null +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -0,0 +1,15 @@ +{ + "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" + ], + "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..250d92d --- /dev/null +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -0,0 +1,12 @@ +{ + "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" + ] +} diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index b5d3545..d030a2d 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -223,14 +223,16 @@ 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) +### 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. +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 } from "@stellar/stellar-sdk"; @@ -263,12 +265,17 @@ if (RECIPIENT && process.env.MPP_SECRET_KEY) { 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: +// 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"); - return next(); // route still responds — unpriced, not broken + res.status(503).json({ error: "MPP charge unavailable — payment middleware not initialized" }); + return; } // ... normal charge flow }; @@ -277,15 +284,18 @@ export function mppChargeMiddleware(amount, description) { 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. +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 route middleware no-op — not -throw — when the instance for that intent is `null`. Charge's and +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`: @@ -322,13 +332,17 @@ 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`) +### Runtime configuration-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: +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) => { @@ -350,7 +364,7 @@ app.get("/info", (_req, res) => { ``` 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 +intent instead of finding out from a 503 that Session was never configured. ## Discovery: let agents find your paid API diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index e80cbff..bdf107c 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -162,7 +162,7 @@ verifiable on Stellar Expert: [`3134a51c…7558bc`](https://stellar.expert/explo **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) +[Recipient resolution](mpp.md#recipient-resolution-recover-at-boot-fail-closed-per-request) 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. From 23b0968b5333a538b2c84be09cf70ac849a3d5e9 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 09:28:20 -0600 Subject: [PATCH 05/19] fix(agentic-payments): sync docs to the two follow-up code fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four gaps the bot's review of the previous commit (23c7988) found, all real, all checked against the actual service and library types before touching anything. 1. x402.md's own "Fail open, not crash" callout still taught the old pattern after mpp.md's matching section was already renamed and rewritten to fail-closed in the previous commit — the anchor link was fixed then, but the prose it points at wasn't, so the link and the text it introduced contradicted each other. Rewritten to describe the real pattern: never throw at boot, but return 503 (not next()) per request once the payment instance is null. This now matches packages/agent/src/middleware/x402.ts on the private side, fixed in the same session this doc change responds to. 2. STELLAR_RECIPIENT was missing from Session mode's own env var list, even though the Session server example requires it as a precondition. Added it, and wired an explicit recipient (plus currency) into both the Session server example and the dual-intent snippet's channel() call — checked against @stellar/mpp/dist/channel/server/Channel.d.ts first: both fields are optional but the library's own doc comment calls them "strongly recommended," specifically because the channel contract is deployed out-of-band and these are what let the library reject a channel whose payout address or token don't match what was expected. 3. The pre-existing Session-mode walkthrough server example (not the newer production-patterns section) had the identical commitmentKey bug already fixed there: MPP_COMMITMENT_KEY is stored as hex, but stellar.channel()'s commitmentKey expects a Stellar G... address (or a Keypair), never raw bytes. Fixed with the same StrKey.encodeEd25519PublicKey() re-encoding, and applied the same fix to the dual-intent snippet's previously-elided commitmentKey. 4. The testnet runbook generated the client's seed (COMMITMENT_SECRET) but never showed how the server's MPP_COMMITMENT_KEY — the public key derived from that same seed — actually gets produced. Added the derivation command and labeled which env var belongs to which side (client holds the seed, server holds the derived public key), since items 2-3 above depend on readers understanding that distinction. evals/05-production-hardening.json gained two more expected_behavior assertions covering the commitmentKey conversion and the recipient/currency wiring, since both are now part of what this scenario's pattern actually teaches. Everything here is a doc-only change; no code in this repo runs, so nothing to re-test beyond re-reading the diff against the library types and the private service's real source, which is what was done before each edit. Co-Authored-By: Claude Sonnet 5 --- .../05-production-hardening.json | 4 +- skills/agentic-payments/mpp.md | 52 +++++++++++++++++-- skills/agentic-payments/x402.md | 15 ++++-- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index 250d92d..4dde99e 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -7,6 +7,8 @@ "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" + "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", + "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" ] } diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index d030a2d..b7f5149 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -138,13 +138,30 @@ 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. +const commitmentKey = StrKey.encodeEd25519PublicKey( + Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") // 64-char hex ed25519 public key +); const mppx = Mppx.create({ secretKey: process.env.MPP_SECRET_KEY, methods: [ stellar.channel({ channel: process.env.MPP_CHANNEL_CONTRACT, // C... contract address - commitmentKey: process.env.MPP_COMMITMENT_KEY, // 64-char hex ed25519 public key + 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", }), @@ -214,7 +231,7 @@ const txHash = await close({ console.log("Channel closed:", txHash); ``` -**Env vars (server):** `MPP_CHANNEL_CONTRACT`, `MPP_COMMITMENT_KEY`, `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 @@ -304,6 +321,7 @@ file means aliasing one — here Channel's becomes `stellarChannel`: import { Mppx } from "mppx/express"; import * as stellar from "@stellar/mpp/charge/server"; import * as stellarChannel from "@stellar/mpp/channel/server"; +import { StrKey } from "@stellar/stellar-sdk"; // RECIPIENT is the resolveRecipient() result from the pattern above. @@ -317,7 +335,24 @@ const sessionMppx = ( RECIPIENT && process.env.MPP_SECRET_KEY ) - ? Mppx.create({ methods: [stellarChannel.channel({ channel: process.env.MPP_CHANNEL_CONTRACT, /* ... */ })] }) + ? Mppx.create({ + methods: [ + stellarChannel.channel({ + channel: process.env.MPP_CHANNEL_CONTRACT, + // Same hex -> G-address re-encoding as the Session server example + // above — MPP_COMMITMENT_KEY is stored as hex, the library wants + // a G... address (or a Keypair), never raw bytes. + commitmentKey: StrKey.encodeEd25519PublicKey( + Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") + ), + // 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, + /* ... */ + }), + ], + }) : null; export const isSessionEnabled = () => !!sessionMppx; @@ -451,11 +486,18 @@ 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): + ```bash + node -e "const {Keypair}=require('@stellar/stellar-sdk');const seed=Buffer.from(process.argv[1],'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 bdf107c..c465f6e 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -159,13 +159,18 @@ 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 +**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. See [Recipient resolution](mpp.md#recipient-resolution-recover-at-boot-fail-closed-per-request) 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. +initialization in a check, and return `503` (not `next()`) for every +request under the paid prefix when the recipient is missing or +initialization failed. ## Buyer: agent client From 2bde1e96dcdc1d543abbc1d0e1cc146218e59612 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 09:34:59 -0600 Subject: [PATCH 06/19] docs(agentic-payments): validate STELLAR_RECIPIENT format in resolveRecipient() Small follow-up flagged as 'your call' in the bot's review: a typo'd G... (wrong length, bad checksum) reached the SDK unvalidated and threw deep inside Mppx.create(), away from the env var that caused it. Added a StrKey.isValidEd25519PublicKey() check as the last step, matching the validation just added to the real service's resolveRecipient() in this session's private-repo commit. Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/mpp.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index b7f5149..703720f 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -252,7 +252,7 @@ free once the server is up. Those are two separate failure surfaces: booting and billing. ```js -import { Keypair } from "@stellar/stellar-sdk"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; function resolveRecipient() { let raw = (process.env.STELLAR_RECIPIENT || "").trim().replace(/['"]/g, ""); @@ -270,6 +270,15 @@ function resolveRecipient() { 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; } From 2a440bddfb19792ef11c4d876ecdea64fd69537d Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 09:43:06 -0600 Subject: [PATCH 07/19] docs(agentic-payments): four -> five env vars in the Session prose Trailing miscount after adding STELLAR_RECIPIENT to Session's env var list two commits ago: the prose right after the dual-intent snippet still said four. Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/mpp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 703720f..d06236b 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -372,7 +372,7 @@ 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 +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. From 8931c8f9619918184324163f5ea11e60deb597f9 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 09:47:05 -0600 Subject: [PATCH 08/19] fix(agentic-payments): facilitator default, missing currency, broken runbook redirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real bugs from this round's review, each verified independently before fixing. 1. x402.md's paymentMiddlewareFromConfig example passed process.env.FACILITATOR_URL straight through with no fallback. Checked the installed @x402/core@2.17.0 source directly: HTTPFacilitatorClient does 'config?.url || DEFAULT_FACILITATOR_URL', and DEFAULT_FACILITATOR_URL is 'https://x402.org/facilitator' — a generic facilitator with no Stellar scheme support, silently substituted for OZ Channels the moment the env var is unset. Added the same explicit default the single-route example above it already had. Our own real x402.ts was never exposed to this — it computes its own network-aware default rather than relying on the library's fallback — so this was doc-only. 2. mpp.md's dual-intent snippet added recipient last round but not currency, the sibling field with the identical rationale (reject a channel paying out in the wrong token). Added, plus the USDC_SAC_TESTNET constant the snippet was missing to reference. 3. The runbook's commitment-key derivation command from last round had a real shell bug: '' as a bare trailing argument is bash input redirection syntax, not a fill-in-the-blank placeholder — copy-pasted as written it does not do what it looks like it does. Rewritten to export the value first and read it via process.env inside the node -e string, verified by hand (export a real seed, run the command, got the expected 64-char hex out). evals/04-multi-route-pricing.json gained an assertion for the facilitator-default fix. Co-Authored-By: Claude Sonnet 5 --- .../agentic-payments/04-multi-route-pricing.json | 3 ++- skills/agentic-payments/mpp.md | 10 ++++++++-- skills/agentic-payments/x402.md | 6 +++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index 5e07ce8..4ce39be 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -7,7 +7,8 @@ "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" + "Does not crash the process at startup when the recipient/payTo env var is unset — wraps middleware initialization in a check instead", + "Sets an explicit fallback for the facilitator URL (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, a generic facilitator with no Stellar scheme support" ], "machine_checkable": [ "Generated server passes tsc --noEmit / node --check against @x402/express, @x402/core, @x402/stellar" diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index d06236b..54bf105 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -332,6 +332,8 @@ import * as stellar from "@stellar/mpp/charge/server"; import * as stellarChannel from "@stellar/mpp/channel/server"; import { 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) @@ -358,6 +360,7 @@ const sessionMppx = ( // 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, /* ... */ }), ], @@ -502,9 +505,12 @@ npm install @stellar/mpp mppx @stellar/stellar-sdk ``` 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): + 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 - node -e "const {Keypair}=require('@stellar/stellar-sdk');const seed=Buffer.from(process.argv[1],'hex');console.log(Keypair.fromRawEd25519Seed(seed).rawPublicKey().toString('hex'))" + 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. diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index c465f6e..7fe7361 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -113,7 +113,11 @@ import { HTTPFacilitatorClient } from "@x402/core/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; const facilitator = new HTTPFacilitatorClient({ - url: process.env.FACILITATOR_URL, + // @x402/core falls back to its own DEFAULT_FACILITATOR_URL + // ("https://x402.org/facilitator") when this is unset — a generic + // facilitator with no Stellar scheme support, not OZ Channels. 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 }; From d963836197d924a6c8cdeb2645711e4def032f21 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 10:19:31 -0600 Subject: [PATCH 09/19] fix(agentic-payments): correct facilitator reasoning, close the multi-route fail-open gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both verified independently before touching anything. 1. x402.org/facilitator DOES support stellar:testnet — checked directly: curl https://x402.org/facilitator/supported lists {scheme: 'exact', network: 'stellar:testnet', extra: {areFeesSponsored: true}}. The previous commit's 'no Stellar scheme support' reasoning for the FACILITATOR_URL default was wrong. The fix itself (the explicit ?? default) was already correct and stays; only the comment explaining WHY needed to change. The real reasons: createAuthHeaders sends the OZ_API_KEY Bearer token to whichever URL ends up configured, so an unset env var silently leaks that credential to an unrelated operator regardless of whether that operator happens to support Stellar; and the same supported.json check confirms there is no stellar:pubnet entry at all, so the identical mistake in a mainnet deployment fails differently (and more confusingly) than in testnet. 2. Real inconsistency Copilot flagged twice without a fix landing: the multi-route paymentMiddlewareFromConfig example still built payTo from process.env.STELLAR_RECIPIENT directly and called paymentMiddlewareFromConfig() unconditionally — no fail-closed guard at all, while every other paid path in this PR (mpp.md's charge/channel middleware, and now this same file's prose two paragraphs down) already fails closed. Added the same pattern: a PAY_TO constant, middleware built for real only when it's set, and a fallback that returns 503 for a request matching one of the configured PAID_ROUTES keys while still falling through to next() for anything that genuinely doesn't match any key — the existing 'falls through unpriced' behavior for unmatched routes is unchanged, only the matched-but-unconfigured case changes. evals/04-multi-route-pricing.json: corrected the facilitator-default assertion's reasoning, and sharpened the existing 'wraps initialization in a check' assertion to require the actual 503-vs-next() split instead of just 'doesn't crash.' Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 4 +- skills/agentic-payments/x402.md | 70 ++++++++++++------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index 4ce39be..23d4287 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -7,8 +7,8 @@ "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", - "Sets an explicit fallback for the facilitator URL (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, a generic facilitator with no Stellar scheme support" + "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: a request matching one of the configured paid route keys gets a 503, not a silent 200; a request matching no key still falls through to next() as usual", + "Sets an explicit fallback for the facilitator URL (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" ], "machine_checkable": [ "Generated server passes tsc --noEmit / node --check against @x402/express, @x402/core, @x402/stellar" diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 7fe7361..317eded 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -114,9 +114,14 @@ import { ExactStellarScheme } from "@x402/stellar/exact/server"; const facilitator = new HTTPFacilitatorClient({ // @x402/core falls back to its own DEFAULT_FACILITATOR_URL - // ("https://x402.org/facilitator") when this is unset — a generic - // facilitator with no Stellar scheme support, not OZ Channels. Set the - // default explicitly instead of relying on that silent fallback. + // ("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}` }; @@ -124,42 +129,56 @@ const facilitator = new HTTPFacilitatorClient({ }, }); +// See mpp.md's Recipient resolution pattern for recovering a secret key +// pasted here by mistake and rejecting a malformed G... — this constant +// keeps that same value in one place instead of reading the env var three +// times below. +const PAY_TO = process.env.STELLAR_RECIPIENT; + const PAID_ROUTES = { "GET /signals": { - accepts: { - scheme: "exact", price: "$0.02", network: NETWORK, - payTo: process.env.STELLAR_RECIPIENT, - }, + 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: process.env.STELLAR_RECIPIENT, - }, + 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: process.env.STELLAR_RECIPIENT, - }, + accepts: { scheme: "exact", price: "$0.25", network: NETWORK, payTo: PAY_TO }, description: "Run a strategy", }, }; -app.use( - paymentMiddlewareFromConfig( +// Fails closed, not open: initialize the real middleware only when PAY_TO +// is set. When it isn't, a request to one of the keys above still gets a +// 503 — never a free 200 — while a request to any other path still falls +// through unpriced, same as always. Same rule as mpp.md's charge/channel +// middleware: a paid production path degrades loudly, never silently. +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 not set — paid routes return 503 instead of pricing"); + x402Middleware = (req, res, next) => { + if (`${req.method} ${req.path}` in PAID_ROUTES) { + return res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT not set" }); + } + next(); + }; +} + +app.use(x402Middleware); ``` 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 +through to `next()` unpriced — that part is unchanged and applies whether +`PAY_TO` is set or not. 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). @@ -169,12 +188,13 @@ 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. See +to offer, which is what the `if (PAY_TO)` guard above does: `503` for +any request matching a key in `PAID_ROUTES`, `next()` for everything +else, same as when the middleware initializes normally. See [Recipient resolution](mpp.md#recipient-resolution-recover-at-boot-fail-closed-per-request) -in mpp.md for the fuller pattern — it applies here too: wrap middleware -initialization in a check, and return `503` (not `next()`) for every -request under the paid prefix when the recipient is missing or -initialization failed. +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 From 5f00ce7f3a6e6d9f7c46c0f207ed4fe11b2af9c0 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 28 Aug 2026 10:36:00 -0600 Subject: [PATCH 10/19] fix(agentic-payments): normalize path in fail-closed fallback, validate commitmentKey length, drop product attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, the first two verified against the real @x402/core source before writing anything. 1. x402.md's fail-closed fallback compared req.method/req.path as raw strings. Checked getRouteConfig() in the installed @x402/core@2.17.0 directly: it uppercases the method and runs a normalizePath() that decodes %2f-encoded slashes, collapses duplicate slashes, and strips a trailing slash, before matching against each route's compiled regex. A plain key-in-object lookup replicated none of that — a request like GET /signals/ or GET //signals, which the real middleware would charge normally, would slip past the unnormalized check and reach the route handler for free during exactly the misconfiguration this fallback exists to guard against. Added a local normalizePath() and route through it. Also documented, not just silently left as a gap: this literal-key lookup can never match wildcard (*), :param, or [param] route keys — @x402/core compiles those to regex (CompiledRoute) at startup — so a deployment using dynamic routes needs real regex matching or a blanket 503, not this exact fallback. 2. mpp.md's two commitmentKey examples never validated length before re-encoding. Confirmed empirically: StrKey.encodeEd25519PublicKey() does not check its input size — a 4-byte buffer encodes to a short-but-plausible-looking G... address instead of throwing. Our own resolveCommitmentKey() in packages/agent/src/middleware/mpp.ts already had this check; the doc examples were a more fragile version of the pattern we ourselves ship. Added the same 32-byte check, computed once in the dual-intent snippet so a malformed env var throws with context before the gate is even evaluated. 3. Per final maintainer direction: removed the sentence attributing the multi-route pattern to 'Nirium's own mainnet endpoint' and its transaction link — official skill pages don't carry product attribution. The pattern and its reasoning are unchanged; only the naming is gone. evals/04-multi-route-pricing.json gained two assertions (path normalization, the dynamic-route-key limitation); evals/05-production-hardening.json gained the commitmentKey length check. Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 2 + .../05-production-hardening.json | 2 +- skills/agentic-payments/mpp.md | 35 ++++++++++---- skills/agentic-payments/x402.md | 47 +++++++++++++++---- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index 23d4287..241245c 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -8,6 +8,8 @@ "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: a request matching one of the configured paid route keys gets a 503, not a silent 200; a request matching no key still falls through to next() as usual", + "Normalizes method (uppercase) and path (trailing slash, duplicate slashes) before matching against the configured route keys in that fallback, rather than comparing req.method/req.path as raw strings — a plain object-key lookup replicates none of @x402/core's own path normalization, so an unnormalized check lets a normal URL variation of a paid route slip through free during exactly the misconfiguration the fallback exists to guard against", + "States that this key-lookup fallback only covers literal PAID_ROUTES paths — wildcard (*), :param, and [param] route keys compile to regex inside @x402/core and can't be matched by a plain object-key check, so a deployment using those needs either equivalent regex matching or a blanket 503 for the whole paid mount point instead", "Sets an explicit fallback for the facilitator URL (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" ], "machine_checkable": [ diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index 4dde99e..c58cd24 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -8,7 +8,7 @@ "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", + "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 — and validates the decoded value is exactly 32 bytes first, since StrKey.encodeEd25519PublicKey() does not check length itself and would otherwise silently encode a truncated or malformed value into something that still looks like a valid address", "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" ] } diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 54bf105..ab7a79b 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -145,9 +145,15 @@ const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDA // 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. -const commitmentKey = StrKey.encodeEd25519PublicKey( - Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") // 64-char hex ed25519 public key -); +// StrKey.encodeEd25519PublicKey() does not validate its input length — a +// truncated or malformed hex value still encodes to something that looks +// like a G... address (just the wrong one) instead of throwing, so check +// the byte length first. +const commitmentKeyBytes = Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex"); +if (commitmentKeyBytes.length !== 32) { + throw new Error(`MPP_COMMITMENT_KEY must be 32 bytes (64 hex chars), got ${commitmentKeyBytes.length}`); +} +const commitmentKey = StrKey.encodeEd25519PublicKey(commitmentKeyBytes); const mppx = Mppx.create({ secretKey: process.env.MPP_SECRET_KEY, @@ -340,9 +346,23 @@ const chargeMppx = (RECIPIENT && process.env.MPP_SECRET_KEY) ? Mppx.create({ methods: [stellar.charge({ recipient: RECIPIENT, /* ... */ })] }) : null; +// Same hex -> G-address re-encoding and 32-byte length check as the Session +// server example above — computed once, before the gate below, so a +// malformed MPP_COMMITMENT_KEY throws with context instead of +// StrKey.encodeEd25519PublicKey() silently encoding the wrong bytes into +// something that still looks like a G... address. +let commitmentKey; +if (process.env.MPP_COMMITMENT_KEY) { + const bytes = Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex"); + if (bytes.length !== 32) { + throw new Error(`MPP_COMMITMENT_KEY must be 32 bytes (64 hex chars), got ${bytes.length}`); + } + commitmentKey = StrKey.encodeEd25519PublicKey(bytes); +} + const sessionMppx = ( process.env.MPP_CHANNEL_CONTRACT && - process.env.MPP_COMMITMENT_KEY && + commitmentKey && RECIPIENT && process.env.MPP_SECRET_KEY ) @@ -350,12 +370,7 @@ const sessionMppx = ( methods: [ stellarChannel.channel({ channel: process.env.MPP_CHANNEL_CONTRACT, - // Same hex -> G-address re-encoding as the Session server example - // above — MPP_COMMITMENT_KEY is stored as hex, the library wants - // a G... address (or a Keypair), never raw bytes. - commitmentKey: StrKey.encodeEd25519PublicKey( - Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") - ), + 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. diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 317eded..3638433 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -150,11 +150,44 @@ const PAID_ROUTES = { }, }; +// getRouteConfig() (inside @x402/core, not exported) uppercases the +// method and normalizes the path — decodes %2f-style encoded slashes, +// collapses duplicate slashes, strips a trailing slash — before matching +// it against each route's compiled regex. A plain `key in PAID_ROUTES` +// lookup replicates none of that, so a request the real middleware would +// charge (GET /signals/, or GET //signals) would slip past an unnormalized +// check and reach the route handler for free during exactly the +// misconfiguration this fallback exists to guard against. +function normalizePath(path) { + return path + .split(/[?#]/)[0] + .replace(/\\/g, "/") + .replace(/\/+/g, "/") + .replace(/(.+?)\/+$/, "$1"); +} + +function isConfiguredPaidRoute(req) { + const key = `${req.method.toUpperCase()} ${normalizePath(req.path)}`; + return key in PAID_ROUTES; +} + // Fails closed, not open: initialize the real middleware only when PAY_TO -// is set. When it isn't, a request to one of the keys above still gets a -// 503 — never a free 200 — while a request to any other path still falls -// through unpriced, same as always. Same rule as mpp.md's charge/channel -// middleware: a paid production path degrades loudly, never silently. +// is set. When it isn't, a request matching one of the keys above still +// gets a 503 — never a free 200 — while a request to any other path still +// falls through unpriced, same as always. Same rule as mpp.md's +// charge/channel middleware: a paid production path degrades loudly, +// never silently. +// +// This only works because every PAID_ROUTES key above is a literal path. +// @x402/core also accepts wildcard (*), named-parameter (:city), and +// bracket-parameter ([id]) keys, and compiles each one to its own regex at +// startup (CompiledRoute) — a plain object-key lookup can never match +// those, present or not. If your own routes use any of those forms, this +// exact fallback isn't enough: either compile the same patterns yourself +// (e.g. with the same path-to-regex library @x402/core uses internally), +// or take the blunter but safe option of returning 503 for every request +// under your paid mount point instead of trying to replicate route +// matching by hand. let x402Middleware; if (PAY_TO) { x402Middleware = paymentMiddlewareFromConfig( @@ -166,7 +199,7 @@ if (PAY_TO) { } else { console.warn("STELLAR_RECIPIENT not set — paid routes return 503 instead of pricing"); x402Middleware = (req, res, next) => { - if (`${req.method} ${req.path}` in PAID_ROUTES) { + if (isConfiguredPaidRoute(req)) { return res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT not set" }); } next(); @@ -178,9 +211,7 @@ app.use(x402Middleware); Each key is `"METHOD /path"`; a request that doesn't match any key falls through to `next()` unpriced — that part is unchanged and applies whether -`PAY_TO` is set or not. 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). +`PAY_TO` is set or not. **Recover at boot, fail closed per request, when the recipient isn't configured.** A server that throws at boot because `STELLAR_RECIPIENT` is From 6bfb4d1b3813246e264aa9bf4b7d123a7e456b8f Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 09:29:07 -0600 Subject: [PATCH 11/19] fix(agentic-payments): stop hand-replicating route matching, isolate Session's own failures, strict hex validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real bugs from this round, all verified before fixing. 1. x402.md's fail-closed fallback still had two real bypasses even after the path-normalization fix last round: no case-folding (GET /Signals) and no percent-decoding (GET /%73ignals), both confirmed against the real matcher — parseRoutePattern() in the installed @x402/core@2.17.0 compiles each route to 'new RegExp(pattern, "i")', case-insensitive, and normalizePath() does %2f-aware decodeURIComponent that this doc's hand-written version never attempted. Rather than keep chasing parity with the library's own matcher one bypass at a time, dropped that approach entirely: the middleware now mounts under its own path prefix (app.use("/paid", x402Middleware)) and the unconfigured fallback returns 503 unconditionally, no route matching at all. Every request that reaches it is a paid route by construction. Same restructuring the caveat comment from last round already named as the safer alternative. 2. mpp.md's dual-intent commitmentKey block threw at import time on a malformed MPP_COMMITMENT_KEY — contradicting this section's own stated rule that no intent's setup may throw or take another intent down with it, and concretely: a bad Session-only env var would have crashed chargeMppx too. Changed to catch and log instead, leaving commitmentKey undefined so sessionMppx's existing gate (commitmentKey &&) fails it closed the same way an unset env var already does, without touching Charge. The standalone Session server example (not under that same 'never throw' section) still throws on purpose — only the dual-intent snippet's behavior changed. 3. Both commitmentKey examples, and the real service's resolveCommitmentKey() this doc reflects, validated length on the *decoded* buffer. Verified empirically: Buffer.from(x, 'hex') stops decoding at the first invalid hex character instead of throwing, so 64 valid characters followed by 64 characters of garbage still decodes to exactly 32 bytes — a length check on the result can't catch that. Replaced with a regex on the raw string, /^[0-9a-f]{64}$/i, before any decoding happens. Also fixed in packages/agent/src/middleware/mpp.ts (private repo, same session, commit f8e99210) — found while writing this doc fix, not the other way around. evals/04-multi-route-pricing.json: replaced the path-normalization and dynamic-route-key assertions with ones matching the new mount-and-503 pattern. evals/05-production-hardening.json: replaced the length-check assertion with the strict-regex one, and added an explicit assertion that Session's own failure never throws or affects Charge. Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 5 +- .../05-production-hardening.json | 4 +- skills/agentic-payments/mpp.md | 44 +++++++---- skills/agentic-payments/x402.md | 77 +++++++------------ 4 files changed, 61 insertions(+), 69 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index 241245c..4c04b14 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -7,9 +7,8 @@ "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: a request matching one of the configured paid route keys gets a 503, not a silent 200; a request matching no key still falls through to next() as usual", - "Normalizes method (uppercase) and path (trailing slash, duplicate slashes) before matching against the configured route keys in that fallback, rather than comparing req.method/req.path as raw strings — a plain object-key lookup replicates none of @x402/core's own path normalization, so an unnormalized check lets a normal URL variation of a paid route slip through free during exactly the misconfiguration the fallback exists to guard against", - "States that this key-lookup fallback only covers literal PAID_ROUTES paths — wildcard (*), :param, and [param] route keys compile to regex inside @x402/core and can't be matched by a plain object-key check, so a deployment using those needs either equivalent regex matching or a blanket 503 for the whole paid mount point instead", + "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", + "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", "Sets an explicit fallback for the facilitator URL (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" ], "machine_checkable": [ diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index c58cd24..5379bc3 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -8,7 +8,9 @@ "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 — and validates the decoded value is exactly 32 bytes first, since StrKey.encodeEd25519PublicKey() does not check length itself and would otherwise silently encode a truncated or malformed value into something that still looks like a valid address", + "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" ] } diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index ab7a79b..f93fbae 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -145,15 +145,18 @@ const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDA // 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. -// StrKey.encodeEd25519PublicKey() does not validate its input length — a -// truncated or malformed hex value still encodes to something that looks -// like a G... address (just the wrong one) instead of throwing, so check -// the byte length first. -const commitmentKeyBytes = Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex"); -if (commitmentKeyBytes.length !== 32) { - throw new Error(`MPP_COMMITMENT_KEY must be 32 bytes (64 hex chars), got ${commitmentKeyBytes.length}`); +// 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(commitmentKeyBytes); +const commitmentKey = StrKey.encodeEd25519PublicKey( + Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex") +); const mppx = Mppx.create({ secretKey: process.env.MPP_SECRET_KEY, @@ -346,18 +349,25 @@ const chargeMppx = (RECIPIENT && process.env.MPP_SECRET_KEY) ? Mppx.create({ methods: [stellar.charge({ recipient: RECIPIENT, /* ... */ })] }) : null; -// Same hex -> G-address re-encoding and 32-byte length check as the Session -// server example above — computed once, before the gate below, so a -// malformed MPP_COMMITMENT_KEY throws with context instead of -// StrKey.encodeEd25519PublicKey() silently encoding the wrong bytes into -// something that still looks like a G... address. +// 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) { - const bytes = Buffer.from(process.env.MPP_COMMITMENT_KEY, "hex"); - if (bytes.length !== 32) { - throw new Error(`MPP_COMMITMENT_KEY must be 32 bytes (64 hex chars), got ${bytes.length}`); + 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"); } - commitmentKey = StrKey.encodeEd25519PublicKey(bytes); } const sessionMppx = ( diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 3638433..fa4da39 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -150,44 +150,23 @@ const PAID_ROUTES = { }, }; -// getRouteConfig() (inside @x402/core, not exported) uppercases the -// method and normalizes the path — decodes %2f-style encoded slashes, -// collapses duplicate slashes, strips a trailing slash — before matching -// it against each route's compiled regex. A plain `key in PAID_ROUTES` -// lookup replicates none of that, so a request the real middleware would -// charge (GET /signals/, or GET //signals) would slip past an unnormalized -// check and reach the route handler for free during exactly the -// misconfiguration this fallback exists to guard against. -function normalizePath(path) { - return path - .split(/[?#]/)[0] - .replace(/\\/g, "/") - .replace(/\/+/g, "/") - .replace(/(.+?)\/+$/, "$1"); -} - -function isConfiguredPaidRoute(req) { - const key = `${req.method.toUpperCase()} ${normalizePath(req.path)}`; - return key in PAID_ROUTES; -} - // Fails closed, not open: initialize the real middleware only when PAY_TO -// is set. When it isn't, a request matching one of the keys above still -// gets a 503 — never a free 200 — while a request to any other path still -// falls through unpriced, same as always. Same rule as mpp.md's -// charge/channel middleware: a paid production path degrades loudly, -// never silently. +// is set. Same rule as mpp.md's charge/channel middleware: a paid +// production path degrades loudly, never silently. // -// This only works because every PAID_ROUTES key above is a literal path. -// @x402/core also accepts wildcard (*), named-parameter (:city), and -// bracket-parameter ([id]) keys, and compiles each one to its own regex at -// startup (CompiledRoute) — a plain object-key lookup can never match -// those, present or not. If your own routes use any of those forms, this -// exact fallback isn't enough: either compile the same patterns yourself -// (e.g. with the same path-to-regex library @x402/core uses internally), -// or take the blunter but safe option of returning 503 for every request -// under your paid mount point instead of trying to replicate route -// matching by hand. +// Don't try to replicate @x402/core's own route matching here to decide +// which unconfigured requests deserve a 503. getRouteConfig() uppercases +// the method, normalizes the path (decodes %2f-style encoded slashes, +// collapses duplicate slashes, strips a trailing slash), and matches +// case-insensitively against each route's compiled regex — and 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( @@ -198,20 +177,21 @@ if (PAY_TO) { ); } else { console.warn("STELLAR_RECIPIENT not set — paid routes return 503 instead of pricing"); - x402Middleware = (req, res, next) => { - if (isConfiguredPaidRoute(req)) { - return res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT not set" }); - } - next(); + x402Middleware = (req, res) => { + res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT not set" }); }; } -app.use(x402Middleware); +// Mounted under /paid rather than at the app root — every request under +// this prefix is a paid route by definition, so the fallback above never +// has to decide which requests it applies to. +app.use("/paid", x402Middleware); ``` -Each key is `"METHOD /path"`; a request that doesn't match any key falls -through to `next()` unpriced — that part is unchanged and applies whether -`PAY_TO` is set or not. +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 @@ -219,9 +199,10 @@ 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: `503` for -any request matching a key in `PAID_ROUTES`, `next()` for everything -else, same as when the middleware initializes normally. See +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 From 8905688875b235da84f0690dca7891716b5c13f1 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 09:42:40 -0600 Subject: [PATCH 12/19] fix(agentic-payments): correct the %2f comment, show handlers moved under /paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real gaps, both from re-reading this round's own comment and example against the real behavior and against what a reader would actually copy. 1. The comment describing @x402/core's normalizePath() had the %2f handling backwards. Verified directly (ran normalizePath() by hand against both inputs): ordinary percent-encoding is what closes the /%73ignals bypass — decodeURIComponent turns %73 into 's', so it matches the /signals route. %2f/%5c are handled the opposite way: deliberately left as literal encoded text, never decoded into a real slash or backslash, specifically so an encoded separator can't be mistaken for a real one. The previous wording implied the %2f handling was the part doing the decoding that matters here; it's the ordinary case that does, and %2f is the one case that's intentionally NOT decoded. 2. Restructuring the mount to /paid last round only showed the middleware moving — the example never showed the actual route handlers moving with it. Copy the snippet as it stood and keep an old app.get(/signals, ...) at the top level, and that handler never passes through x402Middleware at all: free content, regardless of PAY_TO. Added the three handlers registered under /paid/signals, /paid/market, /paid/execute, with a comment naming the exact mistake this closes. evals/04-multi-route-pricing.json: added an assertion for the handler placement. Also merged upstream/main (13 commits behind — unrelated site/CI/docs changes, no conflicts with any file this PR touches) so CI runs against current main instead of failing on an unrelated 13-commit-old base. Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 1 + skills/agentic-payments/x402.md | 43 ++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index 4c04b14..db65a8e 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -9,6 +9,7 @@ "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", "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 (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" ], "machine_checkable": [ diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index fa4da39..314ab40 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -156,17 +156,22 @@ const PAID_ROUTES = { // // Don't try to replicate @x402/core's own route matching here to decide // which unconfigured requests deserve a 503. getRouteConfig() uppercases -// the method, normalizes the path (decodes %2f-style encoded slashes, -// collapses duplicate slashes, strips a trailing slash), and matches -// case-insensitively against each route's compiled regex — and 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. +// 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( @@ -186,6 +191,22 @@ if (PAY_TO) { // this prefix is a paid route by definition, so the fallback above never // has to decide which requests it applies to. 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 From 41e07187f7e3d9fc9b0b3d5fda4973c0cfcf7bfd Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 10:11:02 -0600 Subject: [PATCH 13/19] fix(agentic-payments): call resolveRecipient() for x402's PAY_TO, not a bare env read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real security bug, not a docs nit: PAY_TO was process.env.STELLAR_RECIPIENT directly, while the comment right above it already said 'see mpp.md's Recipient resolution pattern' without the code actually calling that pattern. Two concrete consequences of the gap: payTo is echoed back unvalidated in every 402 response body (unauthenticated, readable by anyone) — a secret key pasted into the env var by mistake would have been broadcast there instead of caught; and a malformed value would have reached paymentMiddlewareFromConfig() as real config, passing the if (PAY_TO) truthy check and skipping the fail-closed fallback this PR already added. Since each of these detail files' code blocks is self-contained (no shared imports across mpp.md/x402.md), inlined the same resolveRecipient() function here rather than leaving it as a cross-file reference: recovers a secret key (S...) into its derived public key with a loud warning, rejects anything that fails StrKey.isValidEd25519PublicKey(), returns '' otherwise — which the existing fail-closed guard already treats as unconfigured. Also fixed in packages/agent/src/middleware/x402.ts (private repo, same session, commit 74d6affa) — found while writing this doc fix, the real service had the identical gap. evals/04-multi-route-pricing.json gained an assertion for this. Co-Authored-By: Claude Sonnet 5 --- .../04-multi-route-pricing.json | 1 + skills/agentic-payments/x402.md | 36 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index db65a8e..d0b4086 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -8,6 +8,7 @@ "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 (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 314ab40..3a8cf88 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -111,6 +111,7 @@ facilitator client and scheme registration yourself: 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 @@ -129,11 +130,36 @@ const facilitator = new HTTPFacilitatorClient({ }, }); -// See mpp.md's Recipient resolution pattern for recovering a secret key -// pasted here by mistake and rejecting a malformed G... — this constant -// keeps that same value in one place instead of reading the env var three -// times below. -const PAY_TO = process.env.STELLAR_RECIPIENT; +// 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": { From e9f4d4265ea170e5a75c21a3563d3e649053a208 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 10:24:13 -0600 Subject: [PATCH 14/19] fix(agentic-payments): add the missing Session middleware factory, declare @stellar/stellar-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real gaps. 1. mpp.md's dual-intent section exported isSessionEnabled() but never a route-middleware equivalent to mppChargeMiddleware for Session. The only prior example of using a Channel instance as route middleware is the standalone Session server walkthrough earlier in the file: sessionMppx.channel({ amount, description }) called directly where a route is registered. Apply that same pattern to the dual-intent snippet's sessionMppx and the call evaluates at route-registration time (module load, i.e. import time) — with sessionMppx null (Session not configured), that throws immediately and crashes the whole process, chargeMppx included. Directly contradicts this section's own stated rule that no intent's setup may throw or take another down with it. Added mppSessionMiddleware, same shape as mppChargeMiddleware: checks the instance, 503s if null, otherwise proceeds. Checked our own real service first (packages/agent/src/middleware/mpp.ts) and it already has this exact function (mppChannelMiddleware, fixed earlier this session) — this was a doc-only gap, the real code was never affected. 2. The Seller example's install command never listed @stellar/stellar-sdk, even though this section's own resolveRecipient() (added two commits ago) imports Keypair and StrKey from it directly. It happens to resolve today only because @x402/stellar pulls it in as a transitive dependency — fragile and easy to break silently on an unrelated version bump. Added it to the install line explicitly. evals/05-production-hardening.json: added an assertion for the new Session middleware factory. Co-Authored-By: Claude Sonnet 5 --- .../05-production-hardening.json | 3 ++- skills/agentic-payments/mpp.md | 21 +++++++++++++++++++ skills/agentic-payments/x402.md | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index 5379bc3..f5d5dc6 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -11,6 +11,7 @@ "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" + "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" ] } diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index f93fbae..01bfc10 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -393,6 +393,27 @@ const sessionMppx = ( : 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; + } + // ... normal channel flow, e.g. await sessionMppx.channel({ amount, description })(req, res, next) + }; +} ``` This is the pattern actually running in production: Charge mode is diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index 3a8cf88..b80d84f 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 ``` From a57a07a89a768ab21441ecc5b4f4f40c0761c4c1 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 10:36:20 -0600 Subject: [PATCH 15/19] fix(agentic-payments): use || not ?? for the facilitator URL default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug, confirmed by hand before fixing: ?? only falls back on null/undefined, not empty string. FACILITATOR_URL= (present but empty in a .env file) passes "" straight through process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet" unchanged, since "" is neither null nor undefined. That "" then reaches @x402/core's own HTTPFacilitatorClient constructor, where this.url = (config?.url || DEFAULT_FACILITATOR_URL).replace(...) treats "" as falsy and silently substitutes the library's own generic x402.org fallback anyway — defeating the whole point of setting an explicit default, and still sending the OZ_API_KEY Bearer token to the wrong operator. Verified: ("" ?? "DEFAULT") === "", ("" || "DEFAULT") === "DEFAULT". Fixed in both places this pattern appears: line 53 (the original single-route Seller example, predates this PR entirely and was never touched until now) and line 126 (the multi-route example, where the bot's review caught it). Checked our own real x402.ts first — it already uses || (`process.env.X402_FACILITATOR_URL || (...)`), so this was doc-only, not a live bug. evals/04-multi-route-pricing.json: corrected the assertion to require ||, with the reasoning. Co-Authored-By: Claude Sonnet 5 --- evals/scenarios/agentic-payments/04-multi-route-pricing.json | 2 +- skills/agentic-payments/x402.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/evals/scenarios/agentic-payments/04-multi-route-pricing.json b/evals/scenarios/agentic-payments/04-multi-route-pricing.json index d0b4086..d1cd834 100644 --- a/evals/scenarios/agentic-payments/04-multi-route-pricing.json +++ b/evals/scenarios/agentic-payments/04-multi-route-pricing.json @@ -11,7 +11,7 @@ "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 (e.g. process.env.FACILITATOR_URL ?? \"https://channels.openzeppelin.com/x402/testnet\") rather than passing an unset env var straight through — @x402/core's HTTPFacilitatorClient silently falls back to its own DEFAULT_FACILITATOR_URL (https://x402.org/facilitator) otherwise, and while that facilitator does support stellar:testnet, an unset env var still sends the OZ_API_KEY Bearer token to the wrong operator and has no stellar:pubnet entry at all for a mainnet deployment" + "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/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index b80d84f..f994642 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -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}` }; @@ -123,7 +123,7 @@ const facilitator = new HTTPFacilitatorClient({ // 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", + 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 }; From 4618db0742a352458219cf6f8f48531867ad8f95 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 10:51:54 -0600 Subject: [PATCH 16/19] fix(agentic-payments): require FEE_PAYER_SECRET in Session's gate, delegate mppChargeMiddleware to a concrete example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug: sessionMppx's construction gate checked only 4 of the 5 vars Session's own env var list two sections above already names as required (MPP_CHANNEL_CONTRACT, commitmentKey, RECIPIENT, MPP_SECRET_KEY — FEE_PAYER_SECRET missing). Without it, sessionMppx still constructs, isSessionEnabled() reports true, /info advertises Session as live — none of that signals anything is wrong until a real close() call runs and throws, since feePayer is required there in @stellar/mpp@0.7.1 even though channel()'s own config types it as merely optional. Added FEE_PAYER_SECRET to the gate and made feePayer unconditional in the channel() config, matching Session's already- correct env var list instead of the other way around. Also fixed in packages/agent/src/middleware/mpp.ts (private repo, same session, commit e65254c2) — found while writing this doc fix, not from a separate report: our own comment there had copied Charge mode's "feePayer is optional (pull vs push)" framing onto Channel, where no client-submitted close path exists to make it actually optional. Second, smaller fix: gave mppChargeMiddleware the same concrete delegation example mppSessionMiddleware already had, instead of the vague "// ... normal charge flow" comment Copilot has flagged repeatedly as an unclear "hang." Both factories now show the identical pattern — delegate to the standalone walkthrough's per-route handler, called manually instead of passed to app.get() — so there's nothing left in either one that reads as unresolved. evals/05-production-hardening.json gained the FEE_PAYER_SECRET assertion. Co-Authored-By: Claude Sonnet 5 --- .../05-production-hardening.json | 3 ++- skills/agentic-payments/mpp.md | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index f5d5dc6..75f43f9 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -12,6 +12,7 @@ "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" + "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" ] } diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index 01bfc10..6e76d27 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -312,7 +312,12 @@ export function mppChargeMiddleware(amount, description) { res.status(503).json({ error: "MPP charge unavailable — payment middleware not initialized" }); return; } - // ... normal charge flow + // 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); }; } ``` @@ -339,7 +344,7 @@ file means aliasing one — here Channel's becomes `stellarChannel`: import { Mppx } from "mppx/express"; import * as stellar from "@stellar/mpp/charge/server"; import * as stellarChannel from "@stellar/mpp/channel/server"; -import { StrKey } from "@stellar/stellar-sdk"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -374,7 +379,16 @@ const sessionMppx = ( process.env.MPP_CHANNEL_CONTRACT && commitmentKey && RECIPIENT && - process.env.MPP_SECRET_KEY + process.env.MPP_SECRET_KEY && + // 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." Leaving it out of this gate let + // sessionMppx construct successfully, isSessionEnabled() report true, + // and /info advertise Session as live — right up until close() actually + // ran and threw for a reason nothing here had signaled in advance. + process.env.FEE_PAYER_SECRET ) ? Mppx.create({ methods: [ @@ -386,7 +400,7 @@ const sessionMppx = ( // leaving the channel's payout address unverified against it. recipient: RECIPIENT, currency: USDC_SAC_TESTNET, - /* ... */ + feePayer: { envelopeSigner: Keypair.fromSecret(process.env.FEE_PAYER_SECRET) }, }), ], }) @@ -411,7 +425,9 @@ export function mppSessionMiddleware(amount, description) { res.status(503).json({ error: "MPP session unavailable — payment middleware not initialized" }); return; } - // ... normal channel flow, e.g. await sessionMppx.channel({ amount, description })(req, res, next) + // 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); }; } ``` From d79ebb57f745597f5deeb1cf1390ad4f3f1eef32 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 12:59:52 -0600 Subject: [PATCH 17/19] fix(agentic-payments): Session's channel() was missing store, feePayer was unvalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs in the dual-intent snippet, both confirmed against the real library types before fixing anything. 1. stellarChannel.channel({...}) had no store at all. channel.Parameters declares store as required (not optional, unlike most of the rest of the config) — omitting it throws at construction time, inside the same ternary that builds sessionMppx. That's exactly the failure this whole section exists to prevent: a bad Session config taking chargeMppx down with it. Added store: Store.memory() explicitly (Charge's version in this same snippet still elides it behind /* ... */ since it was already shown in full in the Recipient resolution section above; Channel's needed to be spelled out since this is the only place it's introduced) and restored the /* ... */ marker after it and feePayer, matching how Charge's own line still has one. 2. feePayer's envelopeSigner was `Keypair.fromSecret(process.env.FEE_PAYER_SECRET)` called directly, with FEE_PAYER_SECRET only checked for presence in the gate above, not validity. Keypair.fromSecret() throws synchronously on a malformed secret key — same failure mode as commitmentKey before its fix. Added feePayerSigner, computed with the same validate-then-use pattern: StrKey.isValidEd25519SecretSeed() on the raw string first; on failure, log and leave it undefined, which the gate (now checking feePayerSigner instead of the raw env var) already treats as Session being unconfigured — same fail-closed-not-throwing behavior the rest of this section establishes, chargeMppx unaffected either way. Also added the missing `import { Store } from "mppx/server"` this snippet needed once store was spelled out (matches every other example in this file). Minor, from the same review round: x402.md's "every request under /paid is paid by definition" comment overclaimed — it's only true for the unconfigured 503 fallback. Once PAY_TO *is* configured, a route under /paid without a matching PAID_ROUTES key still falls through to next() unpriced per the very next paragraph in this same file. Reworded to say what's actually guaranteed (never free by accident during the failure this pattern guards against) instead of implying a blanket invariant the code doesn't provide. evals/05-production-hardening.json: added two assertions (store, feePayerSigner validation). Co-Authored-By: Claude Sonnet 5 --- .../05-production-hardening.json | 4 +- skills/agentic-payments/mpp.md | 40 ++++++++++++++----- skills/agentic-payments/x402.md | 12 ++++-- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index 75f43f9..8f2bc8b 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -13,6 +13,8 @@ "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" + "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 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 6e76d27..a8c8f63 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -342,6 +342,7 @@ 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"; @@ -375,20 +376,31 @@ if (process.env.MPP_COMMITMENT_KEY) { } } +// 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"); + } +} + const sessionMppx = ( process.env.MPP_CHANNEL_CONTRACT && commitmentKey && RECIPIENT && process.env.MPP_SECRET_KEY && - // 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." Leaving it out of this gate let - // sessionMppx construct successfully, isSessionEnabled() report true, - // and /info advertise Session as live — right up until close() actually - // ran and threw for a reason nothing here had signaled in advance. - process.env.FEE_PAYER_SECRET + feePayerSigner ) ? Mppx.create({ methods: [ @@ -400,7 +412,15 @@ const sessionMppx = ( // leaving the channel's payout address unverified against it. recipient: RECIPIENT, currency: USDC_SAC_TESTNET, - feePayer: { envelopeSigner: Keypair.fromSecret(process.env.FEE_PAYER_SECRET) }, + // Required, not optional, in channel.Parameters — unlike Charge + // mode, where store is also required but already shown in full + // in the Recipient resolution section above. 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 }, + /* ... */ }), ], }) diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index f994642..b569ab6 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -213,9 +213,15 @@ if (PAY_TO) { }; } -// Mounted under /paid rather than at the app root — every request under -// this prefix is a paid route by definition, so the fallback above never -// has to decide which requests it applies to. +// 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 From c4e5010d0a3044b0a856badb13db797a0328aea3 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Sat, 29 Aug 2026 13:08:03 -0600 Subject: [PATCH 18/19] fix(agentic-payments): dual-intent store comment pointed at the wrong section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Recipient resolution" doesn't show a store anywhere — it's the STELLAR_RECIPIENT-parsing helper, no Mppx.create() call in it at all. The two real examples are the standalone Charge and Session servers earlier in this file. Repointed the comment at those instead. Co-Authored-By: Claude Sonnet 5 --- skills/agentic-payments/mpp.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skills/agentic-payments/mpp.md b/skills/agentic-payments/mpp.md index a8c8f63..6a9a7f5 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -412,12 +412,12 @@ const sessionMppx = ( // leaving the channel's payout address unverified against it. recipient: RECIPIENT, currency: USDC_SAC_TESTNET, - // Required, not optional, in channel.Parameters — unlike Charge - // mode, where store is also required but already shown in full - // in the Recipient resolution section above. 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. + // 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 }, /* ... */ From c5558662981110abd6b6e1f8490d4ca9755035ed Mon Sep 17 00:00:00 2001 From: Eras256 Date: Mon, 31 Aug 2026 08:31:12 -0600 Subject: [PATCH 19/19] fix(agentic-payments): validate MPP_CHANNEL_CONTRACT before it reaches sessionMppx's gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's latest round on #97: the Session gate at mpp.md checks process.env.MPP_CHANNEL_CONTRACT for presence only, unlike commitmentKey and feePayerSigner right beside it, which are both validated before use. channel() in @stellar/mpp@0.7.1 only validates `store` at construction time — a typo'd C... value still builds sessionMppx, /info still reports Session as enabled, and the bad 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 prevent. StrKey.isValidContract() returns a boolean and never throws, so it fits the same validate-before-use pattern already used for commitmentKey (hex regex) and feePayerSigner (isValidEd25519SecretSeed) two blocks above it. Added a channelAddress variable following that pattern, wired it into the gate and into stellarChannel.channel()'s config, and added the matching assertion to evals/05-production-hardening.json. Also fixed the two small wording items from the same review round: x402.md's and mpp.md's "not set" warnings both cover a value that can also be present-but-invalid (resolveRecipient() returns "" for a malformed STELLAR_RECIPIENT too, and already logs its own specific reason one line earlier) — "not set" contradicted that. Both now say "missing or invalid". Assistance disclosure: Claude Sonnet 5 was used for this round. Co-Authored-By: Claude Sonnet 5 --- .../05-production-hardening.json | 1 + skills/agentic-payments/mpp.md | 22 ++++++++++++++++--- skills/agentic-payments/x402.md | 4 ++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/evals/scenarios/agentic-payments/05-production-hardening.json b/evals/scenarios/agentic-payments/05-production-hardening.json index 8f2bc8b..6847fc2 100644 --- a/evals/scenarios/agentic-payments/05-production-hardening.json +++ b/evals/scenarios/agentic-payments/05-production-hardening.json @@ -14,6 +14,7 @@ "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 6a9a7f5..a75f11b 100644 --- a/skills/agentic-payments/mpp.md +++ b/skills/agentic-payments/mpp.md @@ -297,7 +297,7 @@ 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"); + 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. @@ -395,8 +395,24 @@ if (process.env.FEE_PAYER_SECRET) { } } +// 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 = ( - process.env.MPP_CHANNEL_CONTRACT && + channelAddress && commitmentKey && RECIPIENT && process.env.MPP_SECRET_KEY && @@ -405,7 +421,7 @@ const sessionMppx = ( ? Mppx.create({ methods: [ stellarChannel.channel({ - channel: process.env.MPP_CHANNEL_CONTRACT, + channel: channelAddress, commitmentKey, // Strongly recommended, not just optional: RECIPIENT is already a // precondition to reach this branch, so wire it through instead of diff --git a/skills/agentic-payments/x402.md b/skills/agentic-payments/x402.md index b569ab6..2012c1c 100644 --- a/skills/agentic-payments/x402.md +++ b/skills/agentic-payments/x402.md @@ -207,9 +207,9 @@ if (PAY_TO) { { appName: "My API", testnet: NETWORK === "stellar:testnet" }, ); } else { - console.warn("STELLAR_RECIPIENT not set — paid routes return 503 instead of pricing"); + 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 not set" }); + res.status(503).json({ error: "x402 payment middleware unavailable — STELLAR_RECIPIENT missing or invalid" }); }; }