x402-turbo-stellar
v0.1.0
Published
Session-based micropayment channels for x402 on Stellar
Readme
x402-turbo-stellar
Session-based micropayment channels for the x402 protocol on Stellar.
Pay for API calls with USDC on Stellar — one on-chain deposit, then sub-millisecond per-call payments signed locally. No wallet popup per request, no blockchain round-trip per call. The server batches claims on-chain periodically.
npm install x402-turbo-stellarRequires Node.js ≥ 20. Peer dependency on
expressis optional (Hono is bundled).
Table of Contents
- What is x402-turbo?
- How It Works
- Quick Start — Client
- Quick Start — Server (Hono)
- Quick Start — Server (Express)
- On-Chain Contract
- API Reference
- Types
- Configuration Reference
- Environment Variables
- Demo
- Limitations
What is x402-turbo?
The x402 protocol defines how an HTTP server returns 402 Payment Required and a client
pays before retrying. The baseline implementation settles one payment on-chain per API call —
fast for occasional use but impractical for high-frequency calls (each needs a ~5-second
Stellar transaction confirmation).
x402-turbo adds a session channel mode on top of x402:
| Mode | On-chain txs | Per-call latency | Best for | |------|-------------|------------------|----------| | Single x402 | 1 per call | ~5 s | Occasional / high-value calls | | Session (x402-turbo) | 1 open + 1 close + periodic claims | < 1 ms | High-frequency micropayments |
The client deposits USDC once into a Soroban smart contract escrow, then signs a nonce locally for every subsequent API call. The server verifies the signature in memory (no chain access), serves the response immediately, and batches the actual on-chain settlement in the background.
How It Works
The Session Channel Lifecycle
┌─────────────────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ 1. wrapFetchWithSession(fetch, config) │
│ │ │
│ │ First request (no channel yet) │
│ ├─► GET /api/data ─────────────────────────────────────────────► │
│ │ SERVER │
│ │◄── 402 { accepts: [{ scheme: "session", │
│ │ contract, server, pricePerCall }] } ◄────────────── │
│ │ │
│ │ Open channel (on-chain, one-time ~5s wait) │
│ ├─► open_channel(server, USDC, depositAmount, maxNonce) │
│ │ Soroban contract locks depositAmount USDC in escrow │
│ │ │
│ │ Subsequent requests (< 1 ms per call) │
│ ├─► GET /api/data │
│ │ X-Payment: base64({ │
│ │ scheme: "session", │
│ │ payload: { client, server, nonce, cumulativeAmount, sig } │
│ │ }) │
│ │ │
│ │ Server verifies Ed25519 sig locally → serves immediately │
│ │ Server queues (client, cumulativeAmount, nonce, sig) │
│ │ Server flushes queue on-chain every N calls or T seconds │
│ │ │
│ │ Close channel (on-chain) │
│ └─► close_channel() → unclaimed USDC returned to client │
└─────────────────────────────────────────────────────────────────────────┘Key properties:
- Client deposits once, pays N calls without further wallet interaction
- Each call signs a cumulative amount, not a delta — the contract always knows the total owed, making claims idempotent and safe against replay
- Server keeps only the latest claim per client (higher nonce supersedes all prior)
- Channel expires automatically after
expiryLedgersledgers (~24h default); either party can force-close at expiry to reclaim funds
Signature Scheme
Every call, the client signs a 32-byte SHA-256 hash of a 120-byte preimage:
preimage (120 bytes) =
contractId [0–31] 32 bytes — decoded from C... StrKey
clientAddress [32–63] 32 bytes — decoded from G... StrKey (Ed25519 pubkey)
serverAddress [64–95] 32 bytes — decoded from G... StrKey (Ed25519 pubkey)
nonce [96–103] 8 bytes — big-endian uint64
cumulativeAmount [104–119] 16 bytes — big-endian int128 (signed)
message = SHA256(preimage)
signature = Ed25519.sign(clientSecretKey, message) // 64 bytesThe Soroban contract verifies this exact scheme in claim(). The TypeScript client
produces it in src/signer.ts. Both sides must agree on every byte.
cumulativeAmount is the running total owed — not just this call's price. After call 3
at 0.1 USDC/call, cumulativeAmount = 0.3 USDC. This means any single valid claim
signature proves the client authorised at least that total.
Batch Claiming
The server middleware holds an in-memory BatchClaimManager per middleware instance.
When a valid X-Payment header arrives, the payment is recorded but not yet settled
on-chain. Settlement happens when either:
- The number of pending claims reaches
batchSize(default: 10), or batchIntervalMsmilliseconds elapse (default: 30 000 ms)
Only the latest claim per client is kept in the queue — since amounts are cumulative,
the highest nonce supersedes all earlier ones for that client. This means a single on-chain
claim() transaction settles all calls made by one client since the last flush.
If a claim fails with a permanent contract error (channel closed, expired, over-claimed), it is discarded. Transient network errors are re-queued for the next flush.
Quick Start — Client
import { Keypair } from '@stellar/stellar-sdk'
import { wrapFetchWithSession } from 'x402-turbo-stellar'
const keypair = Keypair.fromSecret(process.env.STELLAR_SECRET_KEY!)
const { fetch, closeChannel, getStatus } = wrapFetchWithSession(globalThis.fetch, {
keypair,
serverAddress: 'GSERVER...', // server's Stellar G... address
contractId: 'CCONTRACT...', // deployed SessionChannel contract ID
network: 'testnet',
depositAmount: 10_000_000n, // 1.0 USDC (7 decimal places on Stellar)
maxNonce: 10_000n, // max calls this channel authorises
pricePerCall: 1_000_000n, // 0.1 USDC per call
})
// First call: automatically opens channel (one on-chain tx, ~5s)
const res = await fetch('https://api.example.com/weather?city=London')
const data = await res.json()
// Subsequent calls: instant — signed locally, no chain access
const res2 = await fetch('https://api.example.com/weather?city=Tokyo')
// Check remaining balance
const status = await getStatus()
console.log(`${status.callsRemaining} calls remaining`)
console.log(`${status.remainingBalance} stroops left`)
// Close channel when done — reclaims unclaimed USDC
const txHash = await closeChannel()
console.log(`Channel closed: https://stellar.expert/explorer/testnet/tx/${txHash}`)What wrapFetchWithSession does automatically:
- On the first call, sends the request bare — receives a
402with server requirements - Opens a channel on-chain (one Stellar transaction)
- Retries the original request with a signed
X-Paymentheader - On all subsequent calls, attaches payment headers proactively (no extra round-trip)
- Advances the nonce and cumulative amount after each call
Quick Start — Server (Hono)
import { Hono } from 'hono'
import { Keypair } from '@stellar/stellar-sdk'
import { stellarSessionMiddleware } from 'x402-turbo-stellar'
const serverKeypair = Keypair.fromSecret(process.env.STELLAR_SERVER_SECRET_KEY!)
const app = new Hono()
// Apply payment middleware to protected routes
app.use('/api/*', stellarSessionMiddleware({
contractId: process.env.SESSION_CONTRACT_ID!,
network: 'testnet',
serverKeypair,
pricePerCall: 1_000_000n, // 0.1 USDC per call
batchSize: 10, // settle every 10 calls
batchIntervalMs: 30_000, // or every 30 seconds
}))
app.get('/api/weather', (c) => {
const city = c.req.query('city') ?? 'London'
return c.json({ city, temp: 18, unit: 'C' })
})
export default appWhen a request arrives without an X-Payment header, the middleware returns:
{
"x402Version": 1,
"error": "Payment Required",
"accepts": [{
"scheme": "session",
"network": "stellar:testnet",
"contractId": "C...",
"server": "G...",
"pricePerCall": "1000000",
"token": "CBIELTK6...",
"maxTimeoutSeconds": 300,
"resource": "/api/weather?city=London",
"mimeType": "application/json"
}]
}Quick Start — Server (Express)
import express from 'express'
import { Keypair } from '@stellar/stellar-sdk'
import { stellarSessionMiddlewareExpress } from 'x402-turbo-stellar'
const serverKeypair = Keypair.fromSecret(process.env.STELLAR_SERVER_SECRET_KEY!)
const app = express()
app.use('/api', stellarSessionMiddlewareExpress({
contractId: process.env.SESSION_CONTRACT_ID!,
network: 'testnet',
serverKeypair,
pricePerCall: 1_000_000n,
}))
app.get('/api/weather', (req, res) => {
res.json({ city: req.query.city, temp: 18, unit: 'C' })
})
app.listen(3001)On-Chain Contract
x402-turbo deploys a Soroban smart contract (SessionChannel) on Stellar. It is the
single source of truth for channel state and enforces all payment invariants.
The contract is written in Rust, compiled to WASM, and runs on Stellar's Soroban VM.
Source: contracts/src/lib.rs.
| Network | Contract ID |
|---------|-------------|
| Testnet | CC7EEA24CUHJCIHN52CPRACBLVHZRVLB4W4BVUJXIF5BEIZNICDSZXHB |
| Mainnet | Not yet deployed — see docs/deployment-checklist.md |
Contract Functions
open_channel
Called by the client to lock USDC in escrow and open a payment channel.
fn open_channel(
env: Env,
client: Address, // must sign this transaction
server: Address,
token: Address, // USDC contract address
amount: i128, // stroops to deposit
max_nonce: u64, // maximum calls authorised in this channel
expiry_ledger: u32, // ledger sequence at which channel auto-expires
) -> Result<(), ContractError>- Transfers
amountUSDC fromclientto the contract's escrow storage - Fails if a channel already exists for this
(client, server)pair max_nonceis enforced by the contract — the server cannot claim nonces above this
claim
Called by the server to settle accumulated payments on-chain.
fn claim(
env: Env,
server: Address, // must sign this transaction
client: Address,
cumulative_amount: i128, // total owed so far (not a delta)
nonce: u64, // must be strictly greater than last_nonce
signature: BytesN<64>, // Ed25519 over (contract, client, server, nonce, cumulative_amount)
) -> Result<i128, ContractError> // returns the delta actually transferred this claim- Verifies the Ed25519 signature against the canonical message format
- Requires
nonce > channel.last_nonce(prevents replay) - Transfers
cumulative_amount - channel.claimedUSDC to the server - Since
cumulative_amountis always increasing, the server can safely claim only the latest signed amount and the delta is computed automatically
close_channel
Called by either the client or the server to initiate or complete channel closure.
fn close_channel(
env: Env,
caller: Address, // must sign this transaction (client or server)
client: Address,
server: Address,
) -> Result<i128, ContractError> // returns amount refunded to client- Sets
closed_by_clientorclosed_by_serverdepending on who called - Channel is fully closed when both parties have called, or when
expiry_ledgerhas passed - On full close, transfers
deposited - claimedback to the client
get_channel
View function — no transaction needed, simulated locally via RPC.
fn get_channel(
env: Env,
client: Address,
server: Address,
) -> Option<ChannelState>Channel State
The contract stores the following per (client, server) pair:
pub struct ChannelState {
pub client: Address,
pub server: Address,
pub token: Address, // USDC contract
pub deposited: i128, // total USDC locked by client
pub claimed: i128, // total claimed by server so far
pub last_nonce: u64, // last nonce server successfully claimed against
pub max_nonce: u64, // ceiling — client cannot authorise more calls
pub expiry_ledger: u32, // ledger at which the channel auto-expires
pub closed_by_client: bool,
pub closed_by_server: bool,
}In TypeScript this is mirrored as the ChannelState interface (exported from the package),
using bigint for all numeric fields.
Error Codes
| Contract error | Value | Meaning |
|---------------|-------|---------|
| ChannelNotFound | 1 | No channel exists for this (client, server) pair |
| ChannelAlreadyExists | 2 | open_channel called when one is already open |
| InvalidSignature | 3 | Ed25519 signature does not verify |
| NonceTooLow | 4 | Claim nonce ≤ last_nonce (replay attempt) |
| NonceTooHigh | 5 | Claim nonce > max_nonce (exceeds client authorisation) |
| InsufficientFunds | 6 | Deposit too low for requested amount |
| ChannelExpired | 7 | expiry_ledger has passed |
| NotAuthorized | 8 | Caller is not the expected party for this operation |
| AlreadyClosed | 9 | Channel is already fully closed |
| OverClaim | 10 | cumulative_amount > deposited |
TypeScript equivalents are in ErrorCodes (exported from the package).
API Reference
Client: wrapFetchWithSession
import { wrapFetchWithSession } from 'x402-turbo-stellar'
function wrapFetchWithSession(
baseFetch: typeof globalThis.fetch,
config: SessionConfig,
): WrappedFetchReturns a WrappedFetch object:
interface WrappedFetch {
fetch: typeof globalThis.fetch // drop-in fetch replacement
closeChannel: () => Promise<string> // returns tx hash
getStatus: () => Promise<ChannelStatus> // on-chain channel state
}Behaviour:
- If the channel is already open,
fetchattaches theX-Paymentheader proactively (no extra 402 round-trip) and advances the nonce after each call - If the channel is not open,
fetchsends the request bare, and on receiving a402it opens the channel on-chain, then retries the original request with payment - If the server does not accept
scheme: "session", the402response is returned as-is for the caller to handle
Client: Channel helpers
Lower-level functions for direct channel control.
import {
openChannel,
closeChannel,
getChannelState,
getChannelStatus,
submitClaim,
} from 'x402-turbo-stellar'openChannel
async function openChannel(opts: {
keypair: Keypair
serverAddress: string
contractId: string
network: StellarNetwork // 'testnet' | 'mainnet'
depositAmount: bigint // in stroops (1 USDC = 10_000_000)
maxNonce: bigint
expiryLedgers?: number // default: 17280 (~24h)
tokenContractId?: string // default: USDC for the network
}): Promise<OpenChannelResult>
interface OpenChannelResult {
txHash: string // open_channel transaction hash
channelId: string // composite key: `${client}:${server}`
state: ChannelState // confirmed on-chain state
}Sends a signed Soroban transaction and polls until confirmed (up to 30s).
Retries once on txBadSeq (stale account sequence number) with a fresh account fetch.
closeChannel
async function closeChannel(opts: {
keypair: Keypair
serverAddress: string
contractId: string
network: StellarNetwork
clientAddress?: string // only needed when the server is the caller
}): Promise<CloseChannelResult>
interface CloseChannelResult {
txHash: string // close_channel transaction hash
refundedToClient: bigint // stroops returned to the client
}getChannelState
async function getChannelState(opts: {
clientAddress: string
serverAddress: string
contractId: string
network: StellarNetwork
}): Promise<ChannelState | null>View call — simulates get_channel locally, no transaction fee or ledger wait.
getChannelStatus
async function getChannelStatus(opts: {
clientAddress: string
serverAddress: string
contractId: string
network: StellarNetwork
pricePerCall: bigint
}): Promise<ChannelStatus>
interface ChannelStatus {
isOpen: boolean
state: ChannelState | null
remainingBalance: bigint // deposited - claimed
callsRemaining: number // remainingBalance / pricePerCall
}submitClaim
async function submitClaim(opts: {
serverKeypair: Keypair
clientAddress: string
contractId: string
network: StellarNetwork
cumulativeAmount: bigint
nonce: bigint
signature: Buffer // 64-byte Ed25519 sig from the client's X-Payment header
}): Promise<{ txHash: string; amountClaimed: bigint }>Called by BatchClaimManager internally. Exported for cases where you want to manage
claim submission yourself (e.g., a dedicated settlement worker process separate from the
web server).
Server: Middleware
stellarSessionMiddleware (Hono)
import { stellarSessionMiddleware } from 'x402-turbo-stellar'
function stellarSessionMiddleware(config: MiddlewareConfig): MiddlewareHandlerWhat it does on each request:
- No
X-Paymentheader → return402withacceptsdescribing session requirements - Decode the base64
X-Paymentheader and parse the JSON payload - Verify
serverfield matches the configuredserverKeypair.publicKey() - Verify
contractIdmatches the configured contract - Verify
cumulativeAmount >= nonce × pricePerCall(price integrity — prevents clients authorising a valid nonce with too small an amount) - Verify the Ed25519 signature using
verifyPaymentSignature - Queue the claim in
BatchClaimManager(in-memory, per middleware instance) - Set
X-Payment-Responseheader (base64 JSON confirming receipt) - Call
next()— request proceeds to your route handler
On invalid payment: returns 402 with { error, reason }.
On malformed header: returns 400.
stellarSessionMiddlewareExpress (Express)
import { stellarSessionMiddlewareExpress } from 'x402-turbo-stellar'
function stellarSessionMiddlewareExpress(config: MiddlewareConfig): RequestHandlerIdentical verification logic with an Express-compatible (req, res, next) interface.
Signing helpers
import {
buildPaymentMessage,
signPaymentNonce,
verifyPaymentSignature,
encodeSignature,
decodeSignature,
} from 'x402-turbo-stellar'buildPaymentMessage
function buildPaymentMessage(
contractId: string,
clientAddress: string,
serverAddress: string,
nonce: bigint,
cumulativeAmount: bigint,
): Buffer // 32-byte SHA256 hashBuilds the canonical message that both the TypeScript client and the Soroban contract
sign and verify. The 120-byte preimage is described in the Signature Scheme
section above. Must match build_payment_message() in contracts/src/lib.rs exactly.
signPaymentNonce
function signPaymentNonce(
keypair: Keypair,
contractId: string,
clientAddress: string,
serverAddress: string,
nonce: bigint,
cumulativeAmount: bigint,
): Buffer // 64-byte Ed25519 signatureverifyPaymentSignature
function verifyPaymentSignature(
clientPublicKeyG: string, // G... Stellar address
contractId: string,
clientAddress: string,
serverAddress: string,
nonce: bigint,
cumulativeAmount: bigint,
signature: Buffer,
): booleanDoes not throw — returns false on any verification failure including malformed input.
encodeSignature / decodeSignature
function encodeSignature(sig: Buffer): string // Buffer → base64
function decodeSignature(sig: string): Buffer // base64 → BufferUsed internally to transport the 64-byte Ed25519 signature as a base64 string inside
the X-Payment JSON payload.
Types
All types are exported from the package root:
import type {
SessionConfig,
MiddlewareConfig,
ChannelState,
ChannelStatus,
SessionPayment,
X402SessionHeader,
SessionPaymentRequirement,
PaymentMode,
WrappedFetch,
OpenChannelResult,
ClaimResult,
CloseChannelResult,
StellarNetwork,
NetworkConfig,
} from 'x402-turbo-stellar'SessionConfig
interface SessionConfig {
keypair: Keypair // client's Stellar keypair
serverAddress: string // G... server address
contractId: string // C... deployed contract
network: StellarNetwork // 'testnet' | 'mainnet'
depositAmount: bigint // stroops to lock (1 USDC = 10_000_000)
maxNonce: bigint // max calls authorised (e.g. 10_000n)
expiryLedgers?: number // ledgers to expiry (default: 17280 ~24h)
pricePerCall?: bigint // override server-advertised price
}MiddlewareConfig
interface MiddlewareConfig {
contractId: string
network: StellarNetwork
serverKeypair: Keypair
pricePerCall: bigint // stroops per API call
batchSize?: number // default: 10
batchIntervalMs?: number // default: 30_000
}X402SessionHeader
The full structure serialised into the X-Payment header (base64-encoded JSON):
interface X402SessionHeader {
x402Version: 1
scheme: 'session'
network: string // 'stellar:testnet' | 'stellar:pubnet'
payload: SessionPayment
}
interface SessionPayment {
client: string // G... client address
server: string // G... server address
contractId: string // C... contract
nonce: bigint // monotonically increasing, starts at 1
cumulativeAmount: bigint // running total owed in stroops
signature: string // base64-encoded 64-byte Ed25519 sig
}
nonceandcumulativeAmountare serialised as strings in JSON (JavaScript cannotJSON.stringifyabigint). The middleware parses them back withBigInt(String(...)).
Network constants
import { STELLAR_TESTNET_CONFIG, STELLAR_MAINNET_CONFIG } from 'x402-turbo-stellar'
// STELLAR_TESTNET_CONFIG
{
network: 'testnet',
networkIdentifier: 'stellar:testnet',
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
usdcContractId: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA',
}
// STELLAR_MAINNET_CONFIG
{
network: 'mainnet',
networkIdentifier: 'stellar:pubnet',
rpcUrl: 'https://horizon.stellar.org',
networkPassphrase: 'Public Global Stellar Network ; September 2015',
usdcContractId: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75',
}X402TurboError
All package errors are instances of X402TurboError:
class X402TurboError extends Error {
code: ErrorCode // one of ErrorCodes.*
cause: unknown // original error or contract response details
}
import { ErrorCodes } from 'x402-turbo-stellar'
// ErrorCodes.CHANNEL_NOT_FOUND
// ErrorCodes.INVALID_SIGNATURE
// ErrorCodes.NONCE_TOO_LOW
// ... (see full list in Error Codes section)Configuration Reference
USDC uses 7 decimal places on Stellar
Unlike most EVM stablecoins (6 decimals), USDC on Stellar uses 7 decimal places. All stroop values in this package follow that convention.
| Human amount | Stroop value |
|-------------|-------------|
| 1.0 USDC | 10_000_000n |
| 0.1 USDC | 1_000_000n |
| 0.01 USDC | 100_000n |
| 0.001 USDC | 10_000n |
Sizing depositAmount and maxNonce
Size them together:
maxNonce ≈ depositAmount / pricePerCallIf depositAmount = 10_000_000 (1 USDC) and pricePerCall = 1_000_000 (0.1 USDC),
set maxNonce = 10. Add 10–20% headroom so the nonce ceiling isn't hit before the
client closes the channel. The contract rejects any claim with nonce > max_nonce.
Tuning batchSize and batchIntervalMs
| Traffic level | batchSize | batchIntervalMs |
|--------------|------------|-------------------|
| Low (< 1 req/s) | 5 | 60_000 |
| Medium (1–10 req/s) | 10 | 30_000 |
| High (> 10 req/s) | 50 | 10_000 |
Lower batch sizes increase on-chain transaction frequency (higher server gas costs). Higher batch sizes widen the window in which funds are unconfirmed if the server crashes mid-batch — though client funds are always safe in escrow.
Environment Variables
# Required for contract interaction
STELLAR_NETWORK=testnet
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
SESSION_CONTRACT_ID=C... # deployed SessionChannel contract
# Keys — never commit, load from a secrets manager in production
STELLAR_SECRET_KEY=S... # client keypair (for tests / scripts)
STELLAR_SERVER_SECRET_KEY=S... # server keypair
# Optional
FRIENDBOT_URL=https://friendbot.stellar.org # for funding testnet accountsDemo
A full-stack demo is included in the repository showing the complete open → pay → close lifecycle with real Stellar testnet transactions:
# Start everything
pnpm demo
# or separately:
pnpm demo:server # weather API server on :3001
pnpm demo:frontend # Next.js frontend on :3000Open http://localhost:3000. Connect a Freighter wallet, open a channel by depositing
1 USDC into escrow, request weather data for any city (paying 0.1 USDC per call via
instant local signing), then close the channel to reclaim unused funds.
Each transaction hash links to Stellar Expert so you can follow the on-chain flow in real time.
Demo stack:
frontend/demo/— Next.js 16, Tailwind CSS v4, Freighter wallet integrationfrontend/weather-server/— Express server withstellarSessionMiddlewareExpress- Stellar testnet USDC, pre-deployed
SessionChannelcontract
Limitations
- Stellar only. No EVM or SVM support.
- USDC only. The contract and helpers are tested with Circle USDC on Stellar. Other SAC-compatible tokens may work but are unsupported.
- Desktop Freighter only for browser wallet flows — Freighter does not support arbitrary message signing on mobile.
- No fiat on-ramp. The client must already hold USDC on Stellar testnet/mainnet.
- XLM still required for fees. Every Stellar account needs a small XLM balance for transaction fees, even in USDC-only flows.
- Single x402 mode not reimplemented. This package delegates per-call on-chain
settlement to the
x402-stellarnpm package. If the server's402response does not includescheme: "session",wrapFetchWithSessionreturns the402as-is. - In-memory claim queue. If the server process crashes between calls and the next batch flush, those queued payments are lost from the server's ledger. Client funds remain safe in escrow — closing the channel returns the full unclaimed balance.
- Testnet resets. Stellar testnet resets periodically. Redeploy the contract and re-fund accounts after a reset. Check the Stellar Discord for reset announcements.
