Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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
}),
],
})
: null;

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

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

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

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

app.listen(3002);
```

### Per-route pricing with middleware factories

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

```js
function chargeMiddleware(amount, description) {
return async (req, res, next) => {
if (!chargeMppx) {
res.setHeader("X-MPP-Warning", "MPP not configured");
return next();
}
try {
const handler = chargeMppx.charge({ amount, description });
// Use Mppx.toNodeListener to bridge Web API ↔ Express
const nodeHandler = Mppx.toNodeListener(handler);
await nodeHandler(req, res);
if (res.statusCode !== 402) next();
} catch (err) {
res.status(500).json({ error: "Payment processing error" });
}
};
}

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

### Recipient key auto-resolution

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

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

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

if (raw.startsWith("S")) {
try {
const pub = Keypair.fromSecret(raw).publicKey();
console.warn(`[MPP] Derived public key from secret: ${pub.slice(0, 8)}...`);
return pub;
} catch {
console.error("[MPP] STELLAR_RECIPIENT looks like a secret but failed to parse");
return "";
}
}
return raw;
}
```
91 changes: 91 additions & 0 deletions skill/x402.md
Original file line number Diff line number Diff line change
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",
});

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

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

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

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

app.listen(3001);
```

### Graceful degradation

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

```js
let x402Middleware;

if (!process.env.STELLAR_RECIPIENT) {
console.warn("[x402] STELLAR_RECIPIENT not set — premium routes open (no payment required)");
x402Middleware = (_req, _res, next) => next();
} else {
try {
x402Middleware = paymentMiddlewareFromConfig(routes, facilitator, schemes);
console.log(`[x402] Payment middleware active | network: ${network}`);
} catch (err) {
console.error("[x402] Failed to initialize:", err);
x402Middleware = (_req, _res, next) => next();
}
}

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

### Payment receipt logging

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

```js
app.get("/api/premium/signals", (req, res) => {
// Non-blocking payment log
logPayment({
from: req.headers["x-payment-from"] || "unknown",
route: req.path,
amount: "0.01",
timestamp: new Date().toISOString(),
}).catch(() => {}); // never fail a request due to logging

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