Agentic Payments: x402 + MPP
Two complementary protocols for AI-agent and machine-to-machine payments on Stellar. Pick based on who depends on whom and how often the agent pays.
Quick decision
| x402 | MPP Charge | MPP Channel | |
|---|---|---|---|
| Per-request on-chain tx? | Yes (via facilitator) | Yes (Soroban SAC) | No (off-chain commits) |
| Needs facilitator? | Yes (OZ Channels) | No | No |
| Client needs XLM? | No (fees sponsored) | Optional (feePayer) | Yes |
| Setup complexity | Low | Low | Medium (deploy contract first) |
| Best for | Quickest setup, fee-free clients | No third-party dep | High-frequency agents |
- Selling an API, want zero-XLM clients → see x402 Seller below
- Calling an x402 API from an agent → see x402 Buyer below
- Selling an API, no facilitator dependency → see MPP Charge below
- Agent making many requests per session → see MPP Channel below
- Unsure → x402 (lowest friction to get started)
All protocols use USDC (SEP-41 SAC) by default; stellar:testnet / stellar:pubnet CAIP-2 network IDs.
Related skills
- The Soroban SACs the protocols call →
../soroban/SKILL.md - USDC and other classic assets →
../assets/SKILL.md - Wallets and signing in the buyer client →
../dapp/SKILL.md - RPC simulation / submission patterns →
../data/SKILL.md - SEP-41 (token interface) and related standards →
../standards/SKILL.md
Part 1: x402 — Paid APIs + Agent Buyer Clients
When to use x402
x402 is the right choice when:
- You want the fastest path to a paid API — minimal code, no contract deployment
- You want clients (including AI agents) to pay with zero XLM — the OZ Channels facilitator sponsors all network fees
- You're building on top of an existing x402 ecosystem (Coinbase, other chains)
Trade-off: you depend on OZ Channels (or a self-hosted relayer) for verification and settlement. If you need zero third-party dependency, use MPP Charge (Part 2 below) instead.
How x402 works on Stellar
Client → GET /resource → Server
Client ← 402 Payment Required (payment requirements) ← Server
Client builds Soroban SAC USDC transfer
Client signs auth entries only (not the full tx envelope)
Client → GET /resource + X-PAYMENT header → Server
Server → OZ Channels /verify + /settle → Stellar (~5s)
Client ← 200 OK + resource
The key Stellar difference: clients sign auth entries, not full transaction envelopes. The facilitator assembles the transaction, pays fees, and submits. Clients need zero XLM.
Seller: monetize an Express API
npm install @x402/express @x402/core @x402/stellar express dotenv
npm pkg set type=module
// 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
createAuthHeaders: async () => {
const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` };
return { verify: h, settle: h, supported: h };
},
});
const resourceServer = new x402ResourceServer(facilitator)
.register(NETWORK, new ExactStellarScheme());
const app = express();
app.use(
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",
},
},
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 (${NETWORK})`));
Env vars:
STELLAR_NETWORK— CAIP-2 network ID; defaults tostellar:testnet. Set tostellar:pubnetfor 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 tohttps://channels.openzeppelin.com/x402for 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
npm install @x402/fetch @x402/stellar dotenv
npm pkg set type=module
// 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, NETWORK);
// 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 fetchWithPayment("http://localhost:3001/weather");
console.log(await res.json());
// Paid automatically: 402 negotiation + auth-entry signing under the hood
Env vars:
STELLAR_NETWORK— CAIP-2 network ID; defaults tostellar: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
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.
-
Generate two keypairs
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()); }" -
Fund both with testnet XLM (friendbot)
curl "https://friendbot.stellar.org?addr=RECIPIENT_G..." curl "https://friendbot.stellar.org?addr=PAYER_G..." -
Add a USDC trustline to BOTH accounts — open Stellar Lab and add a USDC trustline to each
G..., or run via SDK for each keypair:import * as StellarSdk from "@stellar/stellar-sdk"; 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
transfersettles into nothing and the request fails withop_no_trust. -
Fund the PAYER with testnet USDC — open the Circle testnet faucet, select Stellar testnet, paste the payer's
G.... Web Captcha; no API. -
Generate an OZ Channels testnet API key (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. -
Fill in
.envSTELLAR_NETWORK=stellar:testnet STELLAR_RECIPIENT=G... (recipient public key) STELLAR_SECRET_KEY=S... (payer secret key) OZ_API_KEY=... -
Run it
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.
// 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:
import { USDC_TESTNET_ADDRESS, USDC_PUBNET_ADDRESS } from "@x402/stellar";
// USDC_TESTNET_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
// USDC_PUBNET_ADDRESS = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"
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
| Config | Value |
|---|---|
| Network ID | stellar:pubnet |
| RPC URL | Provider-specific endpoint (see Stellar RPC providers directory) |
| Facilitator URL | https://channels.openzeppelin.com/x402 |
| USDC SAC | USDC_PUBNET_ADDRESS from @x402/stellar (currently CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75) |
| OZ Channels API key | Required (channels.openzeppelin.com/gen) |
| Funding | Real USDC on mainnet (CEX, DEX, or bridge) |
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
Auth entry signing — On Stellar, x402 clients sign Soroban authorization entries, not full transaction envelopes. The facilitator assembles the complete transaction. This is lighter than EVM/Solana signing, and means clients never need to manage sequence numbers or pay fees.
Fee sponsorship — OZ Channels pays all Stellar network fees (~$0.00001/tx). Clients need a funded wallet with USDC but zero XLM.
exact-v2 scheme — The Stellar x402 scheme version. Server advertises scheme: "exact" + x402Version: 2. Don't mix v1 and v2 packages.
SAC (Stellar Asset Contract) — USDC on Stellar is a classic asset wrapped in a Soroban contract. x402 payments invoke transfer on the SAC. Any SEP-41 token works; USDC is the default.
Ledger expiration — Auth entries include a max_ledger bound. Use latestLedger + 12 (~1 minute at 5s/ledger). Expired entries fail at settlement.
CAIP-2 network IDs — stellar:testnet and stellar:pubnet. These are the exact strings the protocol expects.
Common pitfalls
Auth entry expired on settle
- Symptom: facilitator returns
isValid: false, error mentions ledger expiration - Fix: ensure client uses
latestLedger + 12(or higher) as expiration; don't cache auth entries across requests
Wrong USDC decimal precision
- Symptom: payment amount off by 10x or 100x
- Fix: Stellar USDC uses 7 decimal places (not 6 like EVM USDC).
$0.001=10000in base units.
V1/V2 package mismatch
- Symptom: TypeScript errors or silent payment failures
- Fix: use all
@x402/*packages at the same major version. V2 is multi-chain; don't import V1@x402/corealongside V2@x402/stellar.
Missing USDC trustline
- Symptom:
op_no_trusterror during settlement - Fix: add a USDC
changeTrustoperation before attempting any x402 payment (see testnet runbook above)
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 (testnet) or channels.openzeppelin.com/gen (mainnet), then set
OZ_API_KEYand pass it viacreateAuthHeaders(see the Seller example).
Trustline missing on the recipient
- Symptom:
op_no_trustduring settlement, even though the client has USDC - Fix: the
payToaccount needs a USDC trustline too. The SACtransfersettles the underlying classic asset, which the recipient cannot hold without a trustline. AddchangeTrustto 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+createEd25519Signertarget 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, orError: 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 withKeypair.fromSecretfirst, and do not pre-convert withgetNetworkPassphrase— both are done internally.// wrong const signer = createEd25519Signer(Keypair.fromSecret(s), getNetworkPassphrase("stellar:testnet")); // right const signer = createEd25519Signer(s, "stellar:testnet");
Part 2: MPP — Machine Payments Protocol (Charge + Channel)
When to use MPP
MPP is the right choice when:
- You want no facilitator dependency — payments settle directly on Stellar via Soroban SAC transfers
- Your AI agent makes many requests per session — use channel mode to pay off-chain and settle once
- You're building a Stellar-native payment stack without relying on third-party infrastructure
Two modes:
| Mode | On-chain txs | Best for |
|---|---|---|
| Charge | One per request | Per-request payments, no pre-funding required |
| Channel | One deposit + one close | High-frequency agents (100s of requests/session) |
If you need zero-XLM clients or the simplest possible setup, use x402 (Part 1 above) instead.
Charge mode: per-request payments
Each request triggers a Soroban SAC token transfer settled on-chain. No facilitator. Server can optionally sponsor fees so clients don't need XLM.
npm install express @stellar/mpp mppx @stellar/stellar-sdk dotenv
npm pkg set type=module
Server:
// charge-server.js
import express from "express";
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/charge/server";
import * as StellarSdk from "@stellar/stellar-sdk";
const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const RECIPIENT = process.env.STELLAR_RECIPIENT; // G... address
const mppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY, // shared secret for credential verification
methods: [
stellar.charge({
recipient: RECIPIENT,
currency: USDC_SAC_TESTNET,
network: "stellar:testnet",
// optional: server pays network fees so clients don't need XLM
feePayer: process.env.FEE_PAYER_SECRET
? { envelopeSigner: StellarSdk.Keypair.fromSecret(process.env.FEE_PAYER_SECRET) }
: undefined,
}),
],
});
const app = express();
app.use(express.json());
// mppx middleware: returns 402 with challenge, then validates payment on retry
app.use(mppx.middleware());
app.get("/data", (req, res) => {
res.json({ result: "paid content", price: "$0.001 USDC" });
});
app.listen(3002, () => console.log("MPP charge server on http://localhost:3002"));
Client:
// charge-client.js
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/charge/client";
import * as StellarSdk from "@stellar/stellar-sdk";
const keypair = StellarSdk.Keypair.fromSecret(process.env.STELLAR_SECRET_KEY);
const mppx = Mppx.create({
methods: [
stellar.charge({
keypair,
mode: "pull", // server assembles and broadcasts the transaction
onProgress(event) {
// event.type: "challenge" | "signed" | "settled"
if (event.type === "settled") console.log("Settled:", event.txHash);
},
}),
],
});
// mppx wraps fetch — 402 handling is transparent
const res = await mppx.fetch("http://localhost:3002/data");
console.log(await res.json());
Env vars (server): STELLAR_RECIPIENT, MPP_SECRET_KEY, FEE_PAYER_SECRET (optional)
Env vars (client): STELLAR_SECRET_KEY
mode: "pull" vs "push":
"pull"— client signs auth entries, server assembles + broadcasts (default; use withfeePayer)"push"— client builds and broadcasts the transaction directly (client must have XLM for fees)
Channel mode: high-frequency off-chain payments
The client deploys a one-way payment channel contract, deposits USDC once, then signs cumulative commitments off-chain for each request. No transaction per request — only two on-chain txs total (deposit + close). Ideal for AI agents making hundreds of calls in a session.
Channel lifecycle
1. Deploy channel contract (one-time) → C... contract address
2. Client deposits USDC into channel → on-chain tx
3. Per request: client signs commitment → off-chain (just a signature)
Amount is cumulative: each sig covers all previous payments + this one
4. Server closes channel when done → on-chain tx, settles total
Prerequisites
- Deploy a one-way-channel Soroban contract to get a
C...contract address - Generate an ed25519 keypair for commitment signing (see stellar-mpp SDK)
- Fund the channel with USDC before making requests
Server:
// channel-server.js
import express from "express";
import { Mppx, Store } from "mppx";
import * as stellar from "@stellar/mpp/channel/server";
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
store: Store.memory(), // dev only — use persistent store in production
network: "stellar:testnet",
}),
],
});
const app = express();
app.use(express.json());
app.use(mppx.middleware());
app.get("/data", (req, res) => {
res.json({ result: "paid content" });
});
app.listen(3003);
Client:
// channel-client.js
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/channel/client";
import * as StellarSdk from "@stellar/stellar-sdk";
// commitment key must be a raw ed25519 seed — NOT a standard Stellar secret key
const commitmentKey = StellarSdk.Keypair.fromRawEd25519Seed(
Buffer.from(process.env.COMMITMENT_SECRET, "hex") // 64-char hex secret
);
const mppx = Mppx.create({
methods: [
stellar.channel({
commitmentKey,
onProgress(event) {
// event.type: "challenge" | "signed"
},
}),
],
});
// Make many requests — each signs a cumulative off-chain commitment
for (let i = 0; i < 100; i++) {
const res = await mppx.fetch("http://localhost:3003/data");
console.log(i, await res.json());
}
Closing the channel (server-initiated):
import { close } from "@stellar/mpp/channel/server";
import * as StellarSdk from "@stellar/stellar-sdk";
const txHash = await close({
channel: process.env.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) },
network: "stellar:testnet",
});
// Single on-chain transaction settles the full session
console.log("Channel closed:", txHash);
Env vars (server): CHANNEL_CONTRACT, COMMITMENT_PUBKEY, MPP_SECRET_KEY, FEE_PAYER_SECRET
Env vars (client): COMMITMENT_SECRET
Packages and subpath imports
npm install @stellar/mpp mppx @stellar/stellar-sdk
| Import path | Recommended import pattern |
|---|---|
@stellar/mpp/charge/server | import * as stellar from "@stellar/mpp/charge/server" — use stellar.charge(...) |
@stellar/mpp/charge/client | import * as stellar from "@stellar/mpp/charge/client" — use stellar.charge(...) |
@stellar/mpp/channel/server | import * as stellar from "@stellar/mpp/channel/server" — use stellar.channel(...), stellar.close(...), stellar.getChannelState(...), stellar.watchChannel(...) |
@stellar/mpp/channel/client | import * as stellar from "@stellar/mpp/channel/client" — use stellar.channel(...) |
@stellar/mpp/channel | Zod schema definitions for channel types |
mppx | import { Mppx, Store } from "mppx" |
Testnet runbook
Steps shared with all protocols:
- Generate keypair + fund with Friendbot (see x402 testnet runbook in Part 1 above)
- Add USDC trustline
- Get testnet USDC from Circle faucet
Channel mode only: 4. Deploy the one-way-channel contract (see stellar-mpp-sdk for deploy script) 5. Generate a 64-char hex ed25519 seed for the commitment key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
- Derive the public key and fund the channel with USDC before making requests
Common pitfalls
Channel: wrong commitment key format
- Symptom:
Keypair.fromRawEd25519Seedthrows or signatures fail to verify - Fix: the commitment key is a raw ed25519 seed as a 64-char hex string — not a Stellar
S...secret key. Generate withcrypto.randomBytes(32).toString('hex').
Channel: non-cumulative amounts
- Symptom: server rejects commitments after the first request
- Fix: each commitment's
amountmust be the running total of all payments so far, not just the price of the current request. The server tracks the highest-seen commitment.
Channel: deposit TTL expired
- Symptom:
close()fails or channel appears drained - Fix: Soroban contract storage has a TTL. Close the channel before it expires, or extend storage TTL via
bumpContractInstance. Don't leave channels open indefinitely.
Charge: client has no XLM for fees
- Symptom:
op_insufficient_balanceor fee errors on client-submitted transactions - Fix: set
mode: "pull"on the client and configurefeePayeron the server so the server pays fees. The client only signs auth entries.
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.