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
126 changes: 126 additions & 0 deletions skills/agentic-payments/mpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,132 @@ console.log("Channel closed:", txHash);
**Env vars (server):** `CHANNEL_CONTRACT`, `COMMITMENT_PUBKEY`, `MPP_SECRET_KEY`, `FEE_PAYER_SECRET`
**Env vars (client):** `COMMITMENT_SECRET`
Comment on lines 217 to 218

## 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, /* ... */ })] })
Comment on lines +296 to +301
: null;
Comment on lines +291 to +302

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.

### Discovery endpoint (`/info`)

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.

## Packages and subpath imports

```bash
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` +
`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