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
144 changes: 140 additions & 4 deletions skills/agentic-payments/mpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down Expand Up @@ -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) },
Expand All @@ -214,9 +214,145 @@ 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

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`. 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;

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;
Comment on lines +300 to +311

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.
Expand Down
68 changes: 68 additions & 0 deletions skills/agentic-payments/x402.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
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:

```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
Expand Down