Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ skill/
├── frontend-stellar-sdk.md # Frontend integration patterns
├── testing.md # Testing strategies
├── stellar-assets.md # Asset issuance and management
├── x402.md # Pay-per-request payments (Coinbase)
├── mpp.md # Machine Payments Protocol (Stellar/Stripe)
├── zk-proofs.md # ZK proof architecture and verification patterns
├── api-rpc-horizon.md # API access (RPC/Horizon)
├── security.md # Security checklist
Expand All @@ -84,6 +86,8 @@ The skill uses a progressive disclosure pattern. The main `SKILL.md` provides co
"How do I deploy a contract to Stellar Testnet?"
"Create unit tests for my Soroban contract"
"Review this contract for security issues"
"Build a paid API using x402 on Stellar"
"Set up MPP charge and channel modes for agent payments"
```

## Contributing
Expand Down
130 changes: 130 additions & 0 deletions skill/mpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,133 @@ npm install @stellar/mpp mppx @stellar/stellar-sdk
**`Store.memory()` in production**
- Symptom: server loses track of channel state on restart, enables double-spend
- Fix: replace `Store.memory()` with a persistent store (database-backed) before going to production.

## Production patterns

### Combining charge + channel in the same server

In production, expose both intents on different route prefixes. Clients choose based on their usage pattern — occasional callers use charge, high-frequency agents use channel:

```js
// dual-mode-server.js
import express from "express";
import { Mppx, Store } from "mppx";
import * as chargeServer from "@stellar/mpp/charge/server";
import * as channelServer from "@stellar/mpp/channel/server";
import * as StellarSdk from "@stellar/stellar-sdk";

const NETWORK = "stellar:testnet";
const USDC_SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const RECIPIENT = process.env.STELLAR_RECIPIENT;

// Charge mode — per-request payments
const chargeMppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY,
methods: [
chargeServer.stellar.charge({
recipient: RECIPIENT,
currency: USDC_SAC,
network: NETWORK,
}),
],
});

// Channel mode — off-chain cumulative payments
const channelMppx = process.env.CHANNEL_CONTRACT
? Mppx.create({
secretKey: process.env.MPP_SECRET_KEY,
methods: [
channelServer.stellar.channel({
channel: process.env.CHANNEL_CONTRACT,
commitmentKey: process.env.COMMITMENT_PUBKEY,
network: NETWORK,
store: Store.memory(), // use persistent store in production
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears under “Production patterns” but uses Store.memory(), which is explicitly unsafe in production (you already note that above). To prevent copy/paste footguns, consider swapping the example to a persistent store placeholder (or a minimal example of one), and keep Store.memory() only in clearly labeled dev/test snippets.

Copilot uses AI. Check for mistakes.
}),
],
})
: null;

const app = express();
app.use(express.json());

// Charge routes
app.use("/api/charge", chargeMppx.middleware());
app.get("/api/charge/data", (_req, res) => res.json({ mode: "charge" }));

// Channel routes (graceful degradation if not configured)
if (channelMppx) {
app.use("/api/channel", channelMppx.middleware());
app.get("/api/channel/data", (_req, res) => res.json({ mode: "channel" }));
}

// Discovery endpoint — clients check which intents are available
app.get("/api/info", (_req, res) => {
res.json({
protocol: "mpp",
intents: {
charge: { enabled: true, routes: { data: "/api/charge/data" } },
channel: {
enabled: !!channelMppx,
contract: process.env.CHANNEL_CONTRACT || null,
routes: channelMppx ? { data: "/api/channel/data" } : {},
},
},
});
});

app.listen(3002);
```

### Per-route pricing with middleware factories

Instead of global middleware, create per-route middleware functions for different price tiers:

```js
function chargeMiddleware(amount, description) {
return async (req, res, next) => {
if (!chargeMppx) {
res.setHeader("X-MPP-Warning", "MPP not configured");
return next();
}
Comment on lines +339 to +342
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this snippet, chargeMppx is always created earlier (const chargeMppx = Mppx.create(...)), so the if (!chargeMppx) branch is effectively dead code and may mislead readers about configuration behavior. Either make chargeMppx conditional on required env vars (so the branch can trigger) or remove this branch from the example.

Suggested change
if (!chargeMppx) {
res.setHeader("X-MPP-Warning", "MPP not configured");
return next();
}

Copilot uses AI. Check for mistakes.
try {
const handler = chargeMppx.charge({ amount, description });
// Use Mppx.toNodeListener to bridge Web API ↔ Express
const nodeHandler = Mppx.toNodeListener(handler);
await nodeHandler(req, res);
if (res.statusCode !== 402) next();
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling next() after nodeHandler(req, res) purely based on statusCode can cause double-handling if the MPP handler already wrote/ended the response (common symptom: “Cannot set headers after they are sent”). Prefer guarding with if (!res.headersSent && !res.writableEnded) (or equivalent) before calling next(), and keep the decision based on whether the handler produced a 402 challenge vs a pass-through.

Suggested change
if (res.statusCode !== 402) next();
const isPaymentChallenge = res.statusCode === 402;
if (isPaymentChallenge) return;
if (!res.headersSent && !res.writableEnded) return next();

Copilot uses AI. Check for mistakes.
} catch (err) {
res.status(500).json({ error: "Payment processing error" });
}
};
}

// Different prices per endpoint
app.get("/signals", chargeMiddleware("0.01", "Market signals"), signalsHandler);
app.get("/market", chargeMiddleware("0.01", "Market data"), marketHandler);
app.post("/execute", chargeMiddleware("0.05", "Strategy execution"), executeHandler);
```

### Recipient key auto-resolution

In containerized deployments (Railway, Docker), environment variables sometimes contain the secret key instead of the public key. Detect and derive automatically:

```js
import { Keypair } from "@stellar/stellar-sdk";

function resolveRecipient() {
let raw = (process.env.STELLAR_RECIPIENT || "").trim().replace(/['"]/g, "");
if (!raw) return "";

if (raw.startsWith("S")) {
try {
const pub = Keypair.fromSecret(raw).publicKey();
console.warn(`[MPP] Derived public key from secret: ${pub.slice(0, 8)}...`);
return pub;
} catch {
console.error("[MPP] STELLAR_RECIPIENT looks like a secret but failed to parse");
return "";
}
}
return raw;
}
```
93 changes: 92 additions & 1 deletion skill/x402.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ app.use(
);

app.get("/weather", (_req, res) => {
res.json({ city: "San Francisco", temp: 18, conditions: "Foggy" });
res.json({ city: "San Partner PM", temp: 18, conditions: "Foggy" });
});

app.listen(3001, () => console.log("x402 server on http://localhost:3001"));
Expand Down Expand Up @@ -201,3 +201,94 @@ Always test on testnet first. Switch by changing `network` and `FACILITATOR_URL`
**OZ Channels 401 on mainnet**
- Symptom: facilitator rejects with 401
- Fix: mainnet requires an API key in the `Authorization: Bearer` header — generate one at channels.openzeppelin.com/gen

## Production patterns

### Multi-route price table

Use `paymentMiddlewareFromConfig` with a route map to set per-endpoint pricing. Paths are relative to the Express mount point — if you mount via `app.use('/api/premium', middleware)`, use `GET /signals` not `GET /api/premium/signals`.

```js
// production-server.js
import express from "express";
import { paymentMiddlewareFromConfig } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactStellarScheme } from "@x402/stellar/exact/server";

const app = express();
const network = process.env.STELLAR_NETWORK === "mainnet" ? "stellar:pubnet" : "stellar:testnet";
const payTo = process.env.STELLAR_RECIPIENT;

const facilitator = new HTTPFacilitatorClient({
url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet",
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example can silently misconfigure mainnet: when STELLAR_NETWORK === "mainnet" but FACILITATOR_URL is unset, it still defaults to the testnet facilitator URL. Consider selecting the default facilitator URL based on network (pubnet vs testnet) to avoid a guaranteed network/URL mismatch.

Suggested change
url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet",
url:
process.env.FACILITATOR_URL ??
(network === "stellar:pubnet"
? "https://channels.openzeppelin.com/x402"
: "https://channels.openzeppelin.com/x402/testnet"),

Copilot uses AI. Check for mistakes.
});

const routes = {
"GET /signals": {
accepts: { scheme: "exact", price: "$0.01", network, payTo, description: "Market signals" },
},
"GET /market": {
accepts: { scheme: "exact", price: "$0.01", network, payTo, description: "Market state" },
},
"POST /execute": {
accepts: { scheme: "exact", price: "$0.05", network, payTo, description: "Strategy execution" },
},
};

const schemes = [{ network, server: new ExactStellarScheme() }];

app.use(
"/api/premium",
paymentMiddlewareFromConfig(routes, facilitator, schemes, {
appName: "My API",
testnet: network === "stellar:testnet",
})
);

app.get("/api/premium/signals", (_req, res) => res.json({ signals: [] }));
app.get("/api/premium/market", (_req, res) => res.json({ market: {} }));
app.post("/api/premium/execute", (_req, res) => res.json({ result: {} }));

app.listen(3001);
```

### Graceful degradation

In production, wrap initialization in a try/catch and fall back to a pass-through middleware when `payTo` is not configured. This prevents crashes during development or CI:

```js
let x402Middleware;

if (!process.env.STELLAR_RECIPIENT) {
console.warn("[x402] STELLAR_RECIPIENT not set — premium routes open (no payment required)");
x402Middleware = (_req, _res, next) => next();
} else {
Comment on lines +262 to +265
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This “graceful degradation” pattern can unintentionally disable payments in production if STELLAR_RECIPIENT is missing/misnamed, opening premium routes without auth/payment. Recommend requiring an explicit opt-in flag for the passthrough behavior (e.g., ALLOW_UNPAID_PREMIUM=true) and/or restricting passthrough to non-production environments to reduce the risk of accidentally running unpaid.

Copilot uses AI. Check for mistakes.
try {
x402Middleware = paymentMiddlewareFromConfig(routes, facilitator, schemes);
console.log(`[x402] Payment middleware active | network: ${network}`);
} catch (err) {
console.error("[x402] Failed to initialize:", err);
x402Middleware = (_req, _res, next) => next();
}
}

app.use("/api/premium", x402Middleware);
```

### Payment receipt logging

Log payment events after the middleware passes (HTTP 200). The `X-PAYMENT` header contains the signed payment proof. Log asynchronously to avoid blocking the response:

```js
app.get("/api/premium/signals", (req, res) => {
// Non-blocking payment log
logPayment({
from: req.headers["x-payment-from"] || "unknown",
Comment on lines +283 to +286
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text says the signed proof is in the X-PAYMENT header, but the example logs x-payment-from and never references X-PAYMENT. To avoid confusion, either update the prose to match the headers actually used, or update the example to log (or hash/store) the X-PAYMENT proof alongside the payer identity.

Suggested change
app.get("/api/premium/signals", (req, res) => {
// Non-blocking payment log
logPayment({
from: req.headers["x-payment-from"] || "unknown",
app.get("/api/premium/signals", (req, res) => {
const paymentProof = req.headers["x-payment"];
const paymentProofHash = paymentProof
? require("crypto").createHash("sha256").update(paymentProof).digest("hex")
: null;
// Non-blocking payment log
logPayment({
from: req.headers["x-payment-from"] || "unknown",
paymentProofHash,

Copilot uses AI. Check for mistakes.
route: req.path,
amount: "0.01",
timestamp: new Date().toISOString(),
}).catch(() => {}); // never fail a request due to logging

res.json({ signals: [] });
});
```