From fddeb4683fe5a7a5ae3c44d3f53ce3b6babc7ddb Mon Sep 17 00:00:00 2001 From: oceans404 Date: Thu, 21 May 2026 07:05:43 -0700 Subject: [PATCH 1/2] fix(agentic-payments): align skill with @x402/* v2.12 and clarify setup The x402 section's seller and buyer samples no longer ran against the published @x402/* v2.12 packages, and the OZ Channels testnet facilitator now requires an API key. Rewrites Part 1 against the current API and fixes the onboarding gaps that came up while standing up a working service end to end. Code changes: - Seller: paymentMiddleware + x402ResourceServer with new ExactStellarScheme(). Route config uses accepts:{scheme:"exact",...}. OZ_API_KEY is required. - Buyer: wrapFetchWithPaymentFromConfig with new ExactStellarScheme(signer). createEd25519Signer takes the raw S... secret and the CAIP-2 ID directly; drops the Keypair indirection and the getNetworkPassphrase double-call. Documentation: - Testnet runbook is now an ordered checklist over two accounts (payer and recipient), with trustlines on BOTH, and a setup.js sketch for the deterministic steps. - Calls out that the Circle faucet and OZ key gen are manual web steps. - New "Two USDC addresses" section distinguishes classic issuer (G...) from SAC contract (C...). References USDC_TESTNET_ADDRESS / USDC_PUBNET_ADDRESS exported by @x402/stellar. - Pitfalls: adds trustline-on-recipient, browser-signing gap, and the Keypair/passphrase argument mistakes. Updates the OZ Channels 401 entry to reflect that the key is required on testnet too. Verified end to end on Stellar testnet: server boots, client paid \$0.001 USDC, settled through OZ Channels, returned 200. Closes #25. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/agentic-payments/SKILL.md | 237 ++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 64 deletions(-) diff --git a/skills/agentic-payments/SKILL.md b/skills/agentic-payments/SKILL.md index e842c0b..3bfd32f 100644 --- a/skills/agentic-payments/SKILL.md +++ b/skills/agentic-payments/SKILL.md @@ -71,35 +71,38 @@ npm pkg set type=module ```js // server.js 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(); - 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 }; + }, }); +const resourceServer = new x402ResourceServer(facilitator) + .register("stellar:testnet", 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: "stellar:testnet", + 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 ) ); @@ -111,87 +114,174 @@ app.listen(3001, () => console.log("x402 server on http://localhost:3001")); ``` **Env vars:** -- `STELLAR_RECIPIENT` — your G... address (receives USDC) -- `OZ_API_KEY` — OZ Channels API key (optional on testnet, required on 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 **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 { 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"; +// 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, "stellar:testnet"); -// createEd25519Signer wraps the keypair for auth-entry signing -const signer = createEd25519Signer(keypair, getNetworkPassphrase(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: "stellar:testnet", 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_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_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 + ``` + +### 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 @@ -200,10 +290,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. Switch by changing `network` and `FACILITATOR_URL`. Both networks require an OZ Channels API key in the `Authorization: Bearer` header. ## Key concepts @@ -237,9 +328,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"); + ``` --- From 45c0518eb1f2db133051c12c5090b71c3885bf1a Mon Sep 17 00:00:00 2001 From: oceans404 Date: Thu, 21 May 2026 07:16:53 -0700 Subject: [PATCH 2/2] fix(agentic-payments): address PR #26 review (dotenv, OZ key guard, NETWORK const) - Add `import "dotenv/config"` to both seller and buyer samples so the .env step the runbook describes actually takes effect. - Throw an explicit error at server startup when OZ_API_KEY is missing, instead of letting createAuthHeaders emit `Bearer undefined` and 401. - Drive the CAIP-2 network ID from a single NETWORK const in both samples (env-overridable via STELLAR_NETWORK). The network is now referenced in one place per file: register(NETWORK), accepts.network, createEd25519Signer second arg, and schemes[].network. Mainnet switch is .env-only, no code edits. Add STELLAR_NETWORK to the runbook .env example and rewrite the mainnet checklist note accordingly. Re-verified end to end on Stellar testnet: missing-key path throws a clear message; happy path settles $0.001 USDC and returns 200. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/agentic-payments/SKILL.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/skills/agentic-payments/SKILL.md b/skills/agentic-payments/SKILL.md index 3bfd32f..782504b 100644 --- a/skills/agentic-payments/SKILL.md +++ b/skills/agentic-payments/SKILL.md @@ -70,11 +70,22 @@ npm pkg set type=module ```js // server.js +import "dotenv/config"; import express from "express"; import { paymentMiddleware, x402ResourceServer } from "@x402/express"; import { HTTPFacilitatorClient } from "@x402/core/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; +// 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", // OZ Channels requires Bearer auth on both testnet and mainnet @@ -85,7 +96,7 @@ const facilitator = new HTTPFacilitatorClient({ }); const resourceServer = new x402ResourceServer(facilitator) - .register("stellar:testnet", new ExactStellarScheme()); + .register(NETWORK, new ExactStellarScheme()); const app = express(); @@ -96,7 +107,7 @@ app.use( accepts: { scheme: "exact", price: "$0.001", // human-readable, auto-converts to 7-decimal USDC units - network: "stellar:testnet", + network: NETWORK, payTo: process.env.STELLAR_RECIPIENT, // recipient G... account }, description: "Current weather data", @@ -110,13 +121,14 @@ 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_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 +- `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 @@ -133,19 +145,22 @@ npm pkg set type=module ```js // client.js +import "dotenv/config"; import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; import { createEd25519Signer } from "@x402/stellar"; import { ExactStellarScheme } from "@x402/stellar/exact/client"; +const NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet"; + // 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, "stellar:testnet"); +const signer = createEd25519Signer(process.env.STELLAR_SECRET_KEY, NETWORK); // wrapFetchWithPaymentFromConfig returns a fetch that handles 402 negotiation // and auth-entry signing transparently. const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, { - schemes: [{ network: "stellar:testnet", client: new ExactStellarScheme(signer) }], + schemes: [{ network: NETWORK, client: new ExactStellarScheme(signer) }], }); const res = await fetchWithPayment("http://localhost:3001/weather"); @@ -154,6 +169,7 @@ console.log(await res.json()); ``` **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. @@ -212,6 +228,7 @@ Two steps are web-only (Captcha or auth form) and cannot be scripted: the Circle 6. **Fill in `.env`** ``` + STELLAR_NETWORK=stellar:testnet STELLAR_RECIPIENT=G... (recipient public key) STELLAR_SECRET_KEY=S... (payer secret key) OZ_API_KEY=... @@ -294,7 +311,7 @@ import { USDC_TESTNET_ADDRESS, USDC_PUBNET_ADDRESS } from "@x402/stellar"; | 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`. Both networks require an OZ Channels API key in the `Authorization: Bearer` header. +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