@stellar/mpp
v0.4.0
Published
Stellar blockchain payment method for the Machine Payments Protocol (MPP)
Readme
@stellar/mpp
Stellar blockchain payment method for the Machine Payments Protocol (MPP). Enables machine-to-machine payments using Soroban SAC token transfers on the Stellar network, with optional support for one-way payment channels for high-frequency off-chain payments.
Specification
The charge payment mode implements the draft-stellar-charge-00 specification (source), which defines the stellar payment method with a charge intent for one-time SEP-41 token transfers under the HTTP Payment Authentication scheme.
Payment modes
Charge (one-time transfers)
Each payment is a Soroban SAC transfer settled on-chain individually.
Client Server Stellar
| | |
| GET /resource | |
|------------------------------>| |
| | |
| 402 Payment Required | |
| (challenge: pay 0.01 USDC) | |
|<------------------------------| |
| | |
| prepareTransaction ----------------- (simulate) ------------>|
| Sign SAC transfer | |
| Send credential (XDR) | |
|------------------------------>| |
| | sendTransaction ------------>|
| | getTransaction (poll) ------>|
| 200 OK + data | |
|<------------------------------| |Two credential modes:
- Pull (default) — client prepares the transaction, server submits it:
- Sponsored (
feePayerconfigured on server): client signs only Soroban auth entries using an all-zeros placeholder source; server rebuilds the tx with its own account as source, signs, and broadcasts - Unsponsored: client builds and signs the full transaction; server broadcasts as-is
- Sponsored (
- Push — client broadcasts the transaction itself, sends the tx hash for server verification (not compatible with
feePayer)
Channel (off-chain commitments)
Uses a one-way payment channel contract. The funder deposits tokens into a channel once, then makes many off-chain payments by signing cumulative commitments — no per-payment on-chain transactions.
Client (Funder) Server (Recipient) Stellar
| | |
| [Channel opened on-chain | |
| with initial deposit] | |
| (see "open" action below) | |
| | |
| GET /resource | |
|------------------------------>| |
| | |
| 402 Payment Required | |
| (pay 1 XLM, cumulative: 0) | |
|<------------------------------| |
| | |
| simulate prepare_commitment------------------------------------>|
| Sign commitment off-chain | |
| (cumulative: 1 XLM + sig) | |
|------------------------------>| |
| | simulate prepare_commitment --->|
| | Verify ed25519 signature |
| 200 OK + data | |
|<------------------------------| |
| | |
| GET /resource (again) | |
|------------------------------>| |
| | |
| 402 (pay 1 XLM, | |
| cumulative: 1 XLM) | |
|<------------------------------| |
| | |
| simulate prepare_commitment------------------------------------>|
| Sign commitment | |
| (cumulative: 2 XLM + sig) | |
|------------------------------>| |
| | simulate prepare_commitment --->|
| | Verify, 200 OK |
|<------------------------------| |
| | |
| | [close channel when convenient] |
| | sendTransaction (close) ------->|Prerequisites
corepack enableInstall
npm install @stellar/mpp @stellar/stellar-sdk mppx@stellar/stellar-sdk and mppx are peer dependencies of @stellar/mpp — you must install compatible versions alongside it.
Only import the subpath you need to keep your bundle small:
# Pick the import for your use case:
import { stellar } from '@stellar/mpp/charge/server' // charge server only
import { stellar } from '@stellar/mpp/charge/client' // charge client only
import { stellar } from '@stellar/mpp/channel/server' // channel server only
import { stellar } from '@stellar/mpp/channel/client' // channel client only
import { parsePort } from '@stellar/mpp/env' // env helpers only
import { USDC_SAC_TESTNET } from '@stellar/mpp' // constants, schemas, error typesQuick start
Server (charge)
import { Mppx, stellar } from '@stellar/mpp/charge/server'
import { USDC_SAC_TESTNET } from '@stellar/mpp'
const mppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY,
methods: [
stellar.charge({
recipient: process.env.STELLAR_RECIPIENT!, // your Stellar public key (G...)
currency: USDC_SAC_TESTNET,
network: 'testnet',
}),
],
})
// Express (example servers use helmet and rate limiting)
export async function handler(request: Request) {
const result = await mppx.charge({
amount: '0.01',
description: 'Premium API access',
})(request)
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: 'paid content here' }))
}Client (charge)
import { Keypair } from '@stellar/stellar-sdk'
import { Mppx, stellar } from '@stellar/mpp/charge/client'
// Polyfills global fetch — 402 responses are handled automatically
Mppx.create({
methods: [
stellar.charge({
keypair: Keypair.fromSecret('S...'),
}),
],
})
const response = await fetch('https://api.example.com/paid-resource')
const data = await response.json()Server (channel)
import { Mppx, stellar, Store } from '@stellar/mpp/channel/server'
const mppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY,
methods: [
stellar.channel({
channel: 'CABC...', // deployed one-way-channel contract address
commitmentKey: 'GFUNDER...', // ed25519 public key for verifying commitments
store: Store.memory(), // tracks cumulative amounts + replay protection
network: 'testnet',
}),
],
})
export async function handler(request: Request) {
const result = await mppx.channel({
amount: '1', // 1 XLM per request (human-readable)
description: 'API call',
})(request)
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: 'paid content here' }))
}Client (channel)
import { Keypair } from '@stellar/stellar-sdk'
import { Mppx, stellar } from '@stellar/mpp/channel/client'
Mppx.create({
methods: [
stellar.channel({
commitmentKey: Keypair.fromSecret('S...'), // ed25519 key matching the channel's commitment_key
}),
],
})
const response = await fetch('https://api.example.com/paid-resource')
const data = await response.json()API
Exports
| Path | Exports |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @stellar/mpp | ChargeMethods, ChannelMethods, constants (USDC_SAC_TESTNET, XLM_SAC_MAINNET, etc.), toBaseUnits, fromBaseUnits, resolveKeypair, Env, Logger (type), error types (StellarMppError, PaymentVerificationError, ChannelVerificationError, SettlementError) |
| @stellar/mpp/charge | charge (method schema) |
| @stellar/mpp/charge/client | stellar, charge, Mppx |
| @stellar/mpp/charge/server | stellar, charge, Mppx, Store, Expires, resolveKeypair |
| @stellar/mpp/channel | channel (method schema) |
| @stellar/mpp/channel/client | stellar, channel, Mppx |
| @stellar/mpp/channel/server | stellar, channel, close, getChannelState, watchChannel, resolveKeypair, Mppx, Store, Expires, ChannelState (type), ChannelEvent (type) |
| @stellar/mpp/env | parseRequired, parseOptional, parsePort, parseStellarPublicKey, parseStellarSecretKey, parseContractAddress, parseHexKey, parseCommaSeparatedList, parseNumber |
Server options (charge)
stellar.charge({
recipient: string, // Stellar public key (G...) or contract (C...)
currency: string, // SAC contract address
network?: 'stellar:testnet' | 'stellar:pubnet', // default: 'stellar:testnet'
decimals?: number, // default: 7
rpcUrl?: string, // custom Soroban RPC URL
feePayer?: { // server-sponsored fee configuration
envelopeSigner: Keypair | string, // source account + envelope signer
feeBumpSigner?: Keypair | string, // wraps sponsored tx in FeeBumpTransaction
},
store?: Store.Store, // replay protection
maxFeeBumpStroops?: number, // max fee bump in stroops (default: 10,000,000)
pollMaxAttempts?: number, // max polling attempts (default: 30)
pollDelayMs?: number, // delay between poll attempts in ms (default: 1,000)
pollTimeoutMs?: number, // overall poll timeout in ms (default: 30,000)
simulationTimeoutMs?: number, // simulation timeout in ms (default: 10,000)
logger?: Logger, // structured logger (default: no-op)
})Client options (charge)
stellar.charge({
keypair?: Keypair, // Stellar Keypair (or use secretKey)
secretKey?: string, // Stellar secret key (S...)
mode?: 'push' | 'pull', // default: 'pull'
timeout?: number, // tx timeout in seconds (default: 180)
decimals?: number, // default: 7
rpcUrl?: string, // custom Soroban RPC URL
pollMaxAttempts?: number, // max polling attempts (default: 30)
pollDelayMs?: number, // delay between poll attempts in ms (default: 1,000)
pollTimeoutMs?: number, // overall poll timeout in ms (default: 30,000)
simulationTimeoutMs?: number, // simulation timeout in ms (default: 10,000)
onProgress?: (event) => void, // lifecycle callback
})Server options (channel)
stellar.channel({
channel: string, // on-chain channel contract address (C...)
commitmentKey: string | Keypair,// ed25519 public key for verifying commitments
network?: 'stellar:testnet' | 'stellar:pubnet', // default: 'stellar:testnet'
decimals?: number, // default: 7
rpcUrl?: string, // custom Soroban RPC URL
sourceAccount?: string, // funded G... address for simulations
store?: Store.Store, // replay protection + cumulative amount tracking
feePayer?: { // fee payer for close/open transactions
envelopeSigner: Keypair | string, // source account + envelope signer
feeBumpSigner?: Keypair | string, // wraps tx in FeeBumpTransaction
},
checkOnChainState?: boolean, // detect on-chain disputes (default: false)
onDisputeDetected?: (state) => void, // callback when close_start detected
maxFeeBumpStroops?: number, // max fee bump in stroops (default: 10,000,000)
pollMaxAttempts?: number, // max polling attempts (default: 30)
pollDelayMs?: number, // delay between poll attempts in ms (default: 1,000)
pollTimeoutMs?: number, // overall poll timeout in ms (default: 30,000)
simulationTimeoutMs?: number, // simulation timeout in ms (default: 10,000)
logger?: Logger, // structured logger (default: no-op)
})Client options (channel)
stellar.channel({
commitmentKey?: Keypair, // ed25519 Keypair for signing commitments
commitmentSecret?: string, // ed25519 secret key (S...)
rpcUrl?: string, // custom Soroban RPC URL
simulationTimeoutMs?: number, // simulation timeout in ms (default: 10,000)
sourceAccount?: string, // funded G... address for simulations
onProgress?: (event) => void, // lifecycle callback
})Progress events
The onProgress callback receives events at each stage:
Charge events:
| Event | Fields | When |
| ------------ | --------------------------------- | ------------------------------------ |
| challenge | recipient, amount, currency | Challenge received |
| signing | --- | Before signing |
| signed | transaction | After signing |
| paying | --- | Before broadcast (push mode) |
| confirming | hash | Polling for confirmation (push mode) |
| paid | hash | Transaction confirmed (push mode) |
Channel events:
| Event | Fields | When |
| ----------- | --------------------------------------- | ------------------------- |
| challenge | channel, amount, cumulativeAmount | Challenge received |
| signing | --- | Before signing commitment |
| signed | cumulativeAmount | Commitment signed |
Fee sponsorship and fee bumping
The feePayer option controls server-sponsored fees per the Stellar Charge spec. It groups two keypairs that operate at different stages of the sponsored transaction lifecycle:
feePayer.envelopeSigner — transaction sponsor
Provides the source account, sequence number, and envelope signature for sponsored transactions. The challenge includes methodDetails.feePayer: true, which tells the client:
- Pull mode is required — the client must send signed XDR for the server to broadcast (push mode is rejected).
- The client builds the transaction with an all-zeros placeholder source and signs only the Soroban authorization entries (not the envelope). The server rebuilds the transaction with its own account and signs the envelope.
Without feePayer, both push and pull modes are available and the client builds and signs the full transaction. Per the spec, unsponsored transactions are submitted as-is — the server MUST NOT modify them.
feePayer.feeBumpSigner — fee bump wrapper (sponsored path only)
When set, the server wraps the sponsored transaction in a FeeBumpTransaction before broadcasting. This is a post-verification step that applies after the server rebuilds and signs the transaction.
feeBumpSigner is nested inside feePayer because it only applies to the sponsored path. The spec requires unsponsored transactions to be submitted without modification, so fee bumping an unsponsored transaction would violate the spec.
import { stellar } from '@stellar/mpp/charge/server'
// Sponsored with fee bumping
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
feePayer: {
envelopeSigner: Keypair.fromSecret('S...'), // source account + envelope sig
feeBumpSigner: Keypair.fromSecret('S...'), // wraps in FeeBumpTransaction
},
})
// Sponsored without fee bumping
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
feePayer: {
envelopeSigner: Keypair.fromSecret('S...'),
},
})
// Unsponsored (no feePayer — both push and pull available)
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
})Replay protection
Provide an mppx Store to prevent challenge reuse:
import { Store } from '@stellar/mpp/charge/server'
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
store: Store.memory(), // or Store.upstash(), Store.cloudflare()
})Logger
Pass a logger to charge or channel servers for structured debug and warning output. The Logger interface is compatible with pino out of the box:
import pino from 'pino'
import { stellar } from '@stellar/mpp/charge/server'
const logger = pino({ level: 'debug' })
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
logger, // pino satisfies the Logger interface natively
})One-way payment channels
Payment channels allow many off-chain micro-payments with minimal on-chain transactions. The one-way-channel contract is deployed on Soroban --- no additional npm dependency is needed.
Prerequisites:
- Deploy the channel contract on Stellar (see one-way-channel repo)
- The funder opens the channel with an initial token deposit, a
commitment_key(ed25519 public key), the recipient address, and a refund waiting period - Both client (funder) and server (recipient) use the channel contract address
How it works:
- The client signs cumulative commitment amounts off-chain using the ed25519 commitment key
- The server verifies signatures by simulating
prepare_commitmenton the channel contract and checking the ed25519 signature - A
Storeis required on the server to track cumulative amounts across requests - The server can call
close()on-chain at any time to settle accumulated payments
Opening a channel via the SDK:
The SDK also supports opening a channel through the MPP 402 flow using the open action. The client builds the deploy transaction externally (e.g., stellar contract deploy --send=no), then passes it as openTransaction context alongside an initial commitment. The server verifies the commitment signature and broadcasts the deploy transaction on-chain. See examples/channel-open.ts for a complete example.
On-chain close (server-side):
import { close } from '@stellar/mpp/channel/server'
await close({
channel: 'CABC...', // channel contract address
amount: 8000000n, // commitment amount to close with
signature: commitmentSigBytes, // ed25519 signature from the latest commitment
feePayer: { envelopeSigner: recipientKeypair },
network: 'stellar:testnet',
})Constants
| Constant | Value |
| ------------------ | ---------------------------------------------------------- |
| USDC_SAC_MAINNET | CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI |
| USDC_SAC_TESTNET | CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA |
| XLM_SAC_MAINNET | CAS3J7GYLGVE45MR3HPSFG352DAANEV5GGMFTO3IZIE4JMCDALQO57Y |
| XLM_SAC_TESTNET | CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC |
Breaking changes from 0.1.0
- Package renamed:
stellar-mpp-sdkis now@stellar/mpp. All import paths change accordingly. - Import paths changed:
stellar-mpp-sdk/clientandstellar-mpp-sdk/serverare no longer valid. Use@stellar/mpp/charge/clientand@stellar/mpp/charge/serverinstead. - Root export renamed:
Methodsis nowChargeMethods. - Store keys changed: Store key format updated to
stellar:{intent}:{type}:{id}. Existing stored data is not backwards compatible. resolveKeypairmoved: Now exported from the root (@stellar/mpp) and from@stellar/mpp/charge/server, no longer fromstellar-mpp-sdk/server.
Environment variables
Example .env files are provided for each demo:
| File | Purpose |
| -------------------------------------- | --------------------------------------------------- |
| examples/.env.charge-server.example | Charge server (recipient key, security settings) |
| examples/.env.charge-client.example | Charge client (secret key, server URL) |
| examples/.env.channel-server.example | Channel server (contract, commitment key, security) |
| examples/.env.channel-client.example | Channel client (commitment secret, server URL) |
Copy the relevant .example file, remove the .example suffix, and fill in your values.
Demo
See demo/README.md for full instructions. Quick start:
# All-in-one (prompts for keys)
./demo/run.sh
# Or two terminals:
STELLAR_RECIPIENT=G... npx tsx examples/charge-server.ts # Terminal 1
STELLAR_SECRET=S... npx tsx examples/charge-client.ts # Terminal 2
# Or npm scripts:
STELLAR_RECIPIENT=G... pnpm demo:charge-server # Terminal 1
STELLAR_SECRET=S... pnpm demo:charge-client # Terminal 2Browser UI available at http://localhost:3000/demo once the server is running.
Channel end-to-end (with on-chain settlement)
Run the full channel lifecycle --- deploy, off-chain payments, and on-chain close --- in a single command:
# Build the one-way-channel WASM first (see https://github.com/stellar-experimental/one-way-channel)
WASM_PATH=path/to/channel.wasm ./demo/run-channel-e2e.shSee demo/channel-e2e-output.txt for example output with Stellar Expert links.
Project structure
Source lives in sdk/src/ with colocated tests (*.test.ts). Each payment mode follows a twin client/server pattern: Methods.ts (Zod schema) + client/ + server/. Shared utilities in shared/ are internal. See examples/ for runnable servers/clients and demo/ for shell scripts.
Development
corepack enable # Enable pnpm via corepack
pnpm install # Install deps (also compiles TypeScript via prepare script)make help # Show all available commands
make check # Run full quality pipeline (format, lint, typecheck, test, build)
make test # Run tests once
make test-watch # Run tests in watch modeSee the Makefile for all targets.
pnpm run build # Compile TypeScript
pnpm run check:types # Type-check without emitting
pnpm run lint # Run ESLint
pnpm run format:check # Check formatting
pnpm test # Run tests (vitest)License
MIT
