Skip to content
Merged
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
254 changes: 190 additions & 64 deletions skills/agentic-payments/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,128 +70,235 @@ npm pkg set type=module

```js
// server.js
import "dotenv/config";
import express from "express";
import { paymentMiddlewareFromConfig } from "@x402/express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactStellarScheme } from "@x402/stellar/exact/server";

const app = express();
// Drive the CAIP-2 network ID from one place. Switching to mainnet means
// flipping STELLAR_NETWORK and FACILITATOR_URL in .env, nothing in code.
const NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet";

if (!process.env.OZ_API_KEY) {
throw new Error(
"OZ_API_KEY is required. Generate one at https://channels.openzeppelin.com/testnet/gen (testnet) or https://channels.openzeppelin.com/gen (mainnet)."
);
}

const facilitator = new HTTPFacilitatorClient({
url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet",
// omit createAuthHeaders on testnet if you don't have an API key yet
createAuthHeaders: process.env.OZ_API_KEY
? async () => {
const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` };
return { verify: h, settle: h, supported: h };
}
: undefined,
// OZ Channels requires Bearer auth on both testnet and mainnet
createAuthHeaders: async () => {
const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` };
return { verify: h, settle: h, supported: h };
},
Comment on lines +91 to +95
});

const resourceServer = new x402ResourceServer(facilitator)
.register(NETWORK, new ExactStellarScheme());

const app = express();

app.use(
paymentMiddlewareFromConfig(
paymentMiddleware(
{
"GET /weather": {
accepts: {
scheme: "exact",
price: "$0.001", // human-readable, auto-converts to 7-decimal USDC units
network: NETWORK,
payTo: process.env.STELLAR_RECIPIENT, // recipient G... account
},
description: "Current weather data",
// human-readable price string — auto-converts to USDC base units
price: "$0.001",
network: "stellar:testnet",
payTo: process.env.STELLAR_RECIPIENT, // your G... address
},
},
{ facilitator, schemes: [ExactStellarScheme] }
resourceServer
)
);

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

app.listen(3001, () => console.log("x402 server on http://localhost:3001"));
app.listen(3001, () => console.log(`x402 server on http://localhost:3001 (${NETWORK})`));
```

**Env vars:**
- `STELLAR_RECIPIENT` — your G... address (receives USDC)
- `OZ_API_KEY` — OZ Channels API key (optional on testnet, required on mainnet)
- `FACILITATOR_URL` — defaults to testnet URL above
- `STELLAR_NETWORK` — CAIP-2 network ID; defaults to `stellar:testnet`. Set to `stellar:pubnet` for mainnet.
- `STELLAR_RECIPIENT` — your G... address (receives USDC, needs a USDC trustline)
- `OZ_API_KEY` — OZ Channels API key (**required on both testnet and mainnet**; generate at the link in the runbook below)
- `FACILITATOR_URL` — defaults to testnet URL above; set to `https://channels.openzeppelin.com/x402` for mainnet

**Price format options:**
- `"$0.001"` — human-readable, auto-converts to 7-decimal USDC units
- `{ amount: "1000", asset: "ASSET_SAC_CONTRACT_ID" }` — explicit base units for non-USDC assets

**`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" below.

## Buyer: agent client

```bash
npm install @x402/fetch @x402/stellar @stellar/stellar-sdk dotenv
npm install @x402/fetch @x402/stellar dotenv
npm pkg set type=module
```

```js
// client.js
import { x402HTTPClient } from "@x402/fetch";
import { createEd25519Signer, getNetworkPassphrase } from "@x402/stellar";
import "dotenv/config";
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { createEd25519Signer } from "@x402/stellar";
import { ExactStellarScheme } from "@x402/stellar/exact/client";
import * as StellarSdk from "@stellar/stellar-sdk";

const keypair = StellarSdk.Keypair.fromSecret(process.env.STELLAR_SECRET_KEY);
const network = "stellar:testnet";
const NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet";

// createEd25519Signer wraps the keypair for auth-entry signing
const signer = createEd25519Signer(keypair, getNetworkPassphrase(network));
// createEd25519Signer takes the raw S... secret string and the CAIP-2 network ID.
// Do NOT pre-wrap with Keypair.fromSecret or call getNetworkPassphrase yourself —
// the signer does both internally.
const signer = createEd25519Signer(process.env.STELLAR_SECRET_KEY, NETWORK);

// x402HTTPClient wraps fetch — handles 402 negotiation transparently
const client = x402HTTPClient({ signer, schemes: [ExactStellarScheme] });
// wrapFetchWithPaymentFromConfig returns a fetch that handles 402 negotiation
// and auth-entry signing transparently.
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: NETWORK, client: new ExactStellarScheme(signer) }],
});

const res = await client.fetch("http://localhost:3001/weather");
const res = await fetchWithPayment("http://localhost:3001/weather");
console.log(await res.json());
// Paid automatically: 402 negotiation + auth-entry signing happens under the hood
// Paid automatically: 402 negotiation + auth-entry signing under the hood
```

**Env vars:**
- `STELLAR_NETWORK` — CAIP-2 network ID; defaults to `stellar:testnet`. Must match the server's network.
- `STELLAR_SECRET_KEY` — your S... secret key (needs USDC trustline + balance)

**Browser frontends:** this client uses Node `fetch` and `createEd25519Signer`, both of which run in Node. A vanilla browser cannot sign Soroban auth entries through a typical wallet extension without additional glue. For a browser payer, run the x402 client server-side and expose a thin proxy endpoint to the page, or wire up Wallets-Kit / Freighter with custom auth-entry signing.

## Testnet runbook

1. **Generate a keypair**
You need two Stellar testnet accounts: a **client/payer** (signs and pays from a USDC balance) and a **server/recipient** (the `payTo` in your route config). Both need a USDC trustline.

Two steps are web-only (Captcha or auth form) and cannot be scripted: the Circle USDC faucet and the OZ Channels key generator. Everything else can be automated. A complete `setup.js` sketch lives at the end of this section.

1. **Generate two keypairs**
```bash
node -e "const { Keypair } = require('@stellar/stellar-sdk'); const kp = Keypair.random(); console.log('Public:', kp.publicKey()); console.log('Secret:', kp.secret());"
node -e "const { Keypair } = require('@stellar/stellar-sdk'); for (const n of ['RECIPIENT','PAYER']) { const k = Keypair.random(); console.log(n, k.publicKey(), k.secret()); }"
```

2. **Fund with testnet XLM**
2. **Fund both with testnet XLM (friendbot)**
```bash
curl "https://friendbot.stellar.org?addr=YOUR_PUBLIC_KEY"
curl "https://friendbot.stellar.org?addr=RECIPIENT_G..."
curl "https://friendbot.stellar.org?addr=PAYER_G..."
```

3. **Add USDC trustline** — open [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test), or via SDK:
3. **Add a USDC trustline to BOTH accounts** — open [Stellar Lab](https://lab.stellar.org/account/fund?network=test) and add a USDC trustline to each `G...`, or run via SDK for each keypair:
```js
import * as StellarSdk from "@stellar/stellar-sdk";

const server = new StellarSdk.Horizon.Server("https://horizon-testnet.stellar.org");
const keypair = process.env.STELLAR_SECRET_KEY
? StellarSdk.Keypair.fromSecret(process.env.STELLAR_SECRET_KEY)
: StellarSdk.Keypair.random();

const account = await server.loadAccount(keypair.publicKey());
const tx = new StellarSdk.TransactionBuilder(account, {
fee: "100",
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.changeTrust({
asset: new StellarSdk.Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"),
}),
)
.setTimeout(30)
.build();
tx.sign(keypair);
await server.submitTransaction(tx);
const horizon = new StellarSdk.Horizon.Server("https://horizon-testnet.stellar.org");
// Circle's classic USDC issuer on Stellar testnet
const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";

async function addTrustline(secret) {
const kp = StellarSdk.Keypair.fromSecret(secret);
const acc = await horizon.loadAccount(kp.publicKey());
const tx = new StellarSdk.TransactionBuilder(acc, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(StellarSdk.Operation.changeTrust({
asset: new StellarSdk.Asset("USDC", USDC_ISSUER),
}))
.setTimeout(60)
.build();
tx.sign(kp);
return horizon.submitTransaction(tx);
}

// Repeat for both the recipient secret and the payer secret.
await addTrustline(process.env.RECIPIENT_SECRET);
await addTrustline(process.env.PAYER_SECRET);
```

Without a trustline on the recipient, the SAC `transfer` settles into nothing and the request fails with `op_no_trust`.

4. **Fund the PAYER with testnet USDC** — open the [Circle testnet faucet](https://faucet.circle.com/), select **Stellar testnet**, paste the payer's `G...`. Web Captcha; no API.

5. **Generate an OZ Channels testnet API key** ([channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen)). **Required, not optional.** Without it the server crashes at startup with `Failed to initialize: no supported payment kinds loaded from any facilitator`.

6. **Fill in `.env`**
```
STELLAR_NETWORK=stellar:testnet
STELLAR_RECIPIENT=G... (recipient public key)
STELLAR_SECRET_KEY=S... (payer secret key)
OZ_API_KEY=...
```

4. **Get testnet USDC** — use the [Circle testnet faucet](https://faucet.circle.com/) (select Stellar testnet)
7. **Run it**
```bash
node server.js
# in another terminal
node client.js
Comment on lines +239 to +241
```

### Optional: setup.js to automate steps 1–3

Drop this in your project and run once. It generates keys, friendbots, and adds USDC trustlines, then writes a starter `.env` so you only need to do the two manual web steps afterward.

```js
// setup.js
import fs from "fs/promises";
import {
Keypair, Horizon, Networks, TransactionBuilder, Operation, Asset, BASE_FEE,
} from "@stellar/stellar-sdk";

const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const horizon = new Horizon.Server("https://horizon-testnet.stellar.org");

const friendbot = (addr) => fetch(`https://friendbot.stellar.org?addr=${addr}`);

async function addTrustline(kp) {
const acc = await horizon.loadAccount(kp.publicKey());
const tx = new TransactionBuilder(acc, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET })
.addOperation(Operation.changeTrust({ asset: new Asset("USDC", USDC_ISSUER) }))
.setTimeout(60).build();
tx.sign(kp);
return horizon.submitTransaction(tx);
}

const recipient = Keypair.random();
const payer = Keypair.random();
await Promise.all([friendbot(recipient.publicKey()), friendbot(payer.publicKey())]);
await new Promise(r => setTimeout(r, 2000));
await Promise.all([addTrustline(recipient), addTrustline(payer)]);

await fs.writeFile(".env", `STELLAR_RECIPIENT=${recipient.publicKey()}
STELLAR_SECRET_KEY=${payer.secret()}
OZ_API_KEY=
`);

console.log(`Fund payer with USDC: https://faucet.circle.com → ${payer.publicKey()}`);
console.log(`Get OZ key: https://channels.openzeppelin.com/testnet/gen → paste into OZ_API_KEY`);
```

## Two USDC addresses (don't confuse them)

USDC on Stellar has two addresses, used in different places. Mixing them up is a common stumble.

| Address | Format | Used for |
|---------|--------|----------|
| Classic asset issuer | `G...` (32-byte ed25519 public key) | The `issuer` of the classic USDC asset; used when adding a trustline (`new Asset("USDC", G...)`) |
| SAC (Soroban Asset Contract) | `C...` (32-byte contract address) | The Soroban contract the protocol invokes `transfer` on; used in payment requirements |

Use the exported constants instead of hard-coding when possible:

```js
import { USDC_TESTNET_ADDRESS, USDC_PUBNET_ADDRESS } from "@x402/stellar";
// USDC_TESTNET_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
// USDC_PUBNET_ADDRESS = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"
```

5. **Get an OZ Channels testnet API key** (optional for testnet, required for mainnet):
- Testnet: [channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen)
- Mainnet: [channels.openzeppelin.com/gen](https://channels.openzeppelin.com/gen)
`payTo` in your route config is always a classic recipient account (`G...`). The SAC address only appears if you set a custom `asset` in the price config for a non-USDC token.

## Mainnet checklist

Expand All @@ -200,10 +307,11 @@ console.log(await res.json());
| Network ID | `stellar:pubnet` |
| RPC URL | Provider-specific endpoint (see [Stellar RPC providers directory](https://developers.stellar.org/docs/data/apis/rpc/providers)) |
| Facilitator URL | `https://channels.openzeppelin.com/x402` |
| USDC SAC | `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75` |
| USDC SAC | `USDC_PUBNET_ADDRESS` from `@x402/stellar` (currently `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`) |
| OZ Channels API key | Required ([channels.openzeppelin.com/gen](https://channels.openzeppelin.com/gen)) |
| Funding | Real USDC on mainnet (CEX, DEX, or bridge) |

Always test on testnet first. Switch by changing `network` and `FACILITATOR_URL`.
Always test on testnet first. To switch a working setup to mainnet, change only the `.env` (`STELLAR_NETWORK=stellar:pubnet`, `FACILITATOR_URL=https://channels.openzeppelin.com/x402`, mainnet `OZ_API_KEY`, and a mainnet `STELLAR_RECIPIENT`); the samples derive their network from `STELLAR_NETWORK`, so no code changes are needed. Both networks require an OZ Channels API key in the `Authorization: Bearer` header.

## Key concepts

Expand Down Expand Up @@ -237,9 +345,27 @@ Always test on testnet first. Switch by changing `network` and `FACILITATOR_URL`
- Symptom: `op_no_trust` error during settlement
- Fix: add a USDC `changeTrust` operation before attempting any x402 payment (see testnet runbook above)

**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
**OZ Channels 401 on testnet or mainnet**
- Symptom: facilitator rejects with 401, server logs `Failed to initialize: no supported payment kinds loaded from any facilitator`
- Fix: an API key is required on **both** networks (this is a recent change). Generate one at [channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen) (testnet) or [channels.openzeppelin.com/gen](https://channels.openzeppelin.com/gen) (mainnet), then set `OZ_API_KEY` and pass it via `createAuthHeaders` (see the Seller example).

**Trustline missing on the recipient**
- Symptom: `op_no_trust` during settlement, even though the client has USDC
- Fix: the `payTo` account needs a USDC trustline too. The SAC `transfer` settles the underlying classic asset, which the recipient cannot hold without a trustline. Add `changeTrust` to both accounts during setup.

**Trying to sign auth entries from a browser**
- Symptom: bundling errors, or a browser wallet that has no API to sign Soroban auth entries
- Fix: run the x402 client server-side (e.g. an Express route the browser calls), or use Wallets-Kit / Freighter with custom auth-entry signing. `@x402/fetch` + `createEd25519Signer` target Node and assume a raw secret key.

**Passing a `Keypair` (or a network passphrase) to `createEd25519Signer`**
- Symptom: `TypeError: encoded argument must be of type String`, or `Error: Unknown Stellar network: Test SDF Network ; September 2015`
- Fix: the signer takes the raw `S...` secret string and a CAIP-2 network ID. Do **not** wrap with `Keypair.fromSecret` first, and do **not** pre-convert with `getNetworkPassphrase` — both are done internally.
```js
// wrong
const signer = createEd25519Signer(Keypair.fromSecret(s), getNetworkPassphrase("stellar:testnet"));
// right
const signer = createEd25519Signer(s, "stellar:testnet");
```

---

Expand Down