npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-stellar

Requires Node.js ≥ 20. Peer dependency on express is optional (Hono is bundled).


Table of Contents


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 expiryLedgers ledgers (~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 bytes

The 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
  • batchIntervalMs milliseconds 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:

  1. On the first call, sends the request bare — receives a 402 with server requirements
  2. Opens a channel on-chain (one Stellar transaction)
  3. Retries the original request with a signed X-Payment header
  4. On all subsequent calls, attaches payment headers proactively (no extra round-trip)
  5. 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 app

When 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 amount USDC from client to the contract's escrow storage
  • Fails if a channel already exists for this (client, server) pair
  • max_nonce is 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.claimed USDC to the server
  • Since cumulative_amount is 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_client or closed_by_server depending on who called
  • Channel is fully closed when both parties have called, or when expiry_ledger has passed
  • On full close, transfers deposited - claimed back 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,
): WrappedFetch

Returns 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, fetch attaches the X-Payment header proactively (no extra 402 round-trip) and advances the nonce after each call
  • If the channel is not open, fetch sends the request bare, and on receiving a 402 it opens the channel on-chain, then retries the original request with payment
  • If the server does not accept scheme: "session", the 402 response 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): MiddlewareHandler

What it does on each request:

  1. No X-Payment header → return 402 with accepts describing session requirements
  2. Decode the base64 X-Payment header and parse the JSON payload
  3. Verify server field matches the configured serverKeypair.publicKey()
  4. Verify contractId matches the configured contract
  5. Verify cumulativeAmount >= nonce × pricePerCall (price integrity — prevents clients authorising a valid nonce with too small an amount)
  6. Verify the Ed25519 signature using verifyPaymentSignature
  7. Queue the claim in BatchClaimManager (in-memory, per middleware instance)
  8. Set X-Payment-Response header (base64 JSON confirming receipt)
  9. 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): RequestHandler

Identical 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 hash

Builds 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 signature

verifyPaymentSignature

function verifyPaymentSignature(
  clientPublicKeyG: string,   // G... Stellar address
  contractId:       string,
  clientAddress:    string,
  serverAddress:    string,
  nonce:            bigint,
  cumulativeAmount: bigint,
  signature:        Buffer,
): boolean

Does 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 → Buffer

Used 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
}

nonce and cumulativeAmount are serialised as strings in JSON (JavaScript cannot JSON.stringify a bigint). The middleware parses them back with BigInt(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 / pricePerCall

If 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 accounts

Demo

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 :3000

Open 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 integration
  • frontend/weather-server/ — Express server with stellarSessionMiddlewareExpress
  • Stellar testnet USDC, pre-deployed SessionChannel contract

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-stellar npm package. If the server's 402 response does not include scheme: "session", wrapFetchWithSession returns the 402 as-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.