Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

HTTP has had a 402 Payment Required status code since 1999 and no agreed meaning for it. Two protocols now give it one: x402, built around a GET → 402 → signed payment → retry flow, and MPP’s draft-stellar-charge-00, built on a formal WWW-Authenticate: Payment HTTP authentication scheme. Both exist because AI agents and automated services need to pay for API access without a human present to click “approve.”

sorocharge-core implements both protocols on Stellar, sharing one signing engine underneath instead of building two separate ones. That sharing is not just an implementation convenience: both protocols reduce to the same primitive — a signed Soroban authorization for a SEP-41 transfer(from, to, amount) call — and differ only in how that authorization gets from payer to payee over HTTP.

What this is

Three Rust crates:

  • sorocharge-signer builds, signs, and verifies a SorobanAuthorizationEntry for a SEP-41 transfer, across every CAP-71 credential shape: legacy SorobanAddressCredentials, CAP-71 V2 (AddressV2), and delegated signers (AddressWithDelegates). It knows nothing about HTTP, x402, or MPP.
  • sorocharge-x402 implements the payer client and facilitator sides of x402 protocol v2’s exact scheme on Stellar.
  • sorocharge-mpp implements the payer client and server sides of MPP’s draft-stellar-charge-00 "stellar"/"charge" method, in both its pull (sponsored and unsponsored) and push settlement modes.

Every function in sorocharge-signer that produces XDR is diffed byte-for-byte in CI against fixtures generated from a pinned @stellar/stellar-sdk version — not just checked for “looks plausible,” but matched against what the reference SDK actually produces for the same inputs. See Developer setup for how to reproduce that yourself.

What this is not

  • Not MPP session/channel mode. That mode signs a raw ed25519 signature over a one-way-channel contract’s prepare_commitment output — a different primitive entirely, with no SorobanAuthorizationEntry involved. It does not exist anywhere in this repository.
  • Not a general-purpose Soroban contract client. Everything here is scoped to one operation: build, sign, and verify a SEP-41 transfer authorization.
  • Not a key manager. Every function that signs takes a Signer the caller already controls. No key storage, no HSM integration, no wallet.
  • Not an HTTP server. sorocharge-x402’s Facilitator and sorocharge-mpp’s MppServer are plain functions over already-deserialized request types — wiring them to real /verify, /settle, or WWW-Authenticate HTTP handling is the embedding application’s job, using whatever web framework it already runs.

Where to go next

Three crates, one engine

sorocharge-signer   the shared engine: build/sign/verify a SEP-41 transfer
                     authorization entry, for every CAP-71 credential shape
sorocharge-x402      x402 protocol v2, "exact" scheme, on Stellar
sorocharge-mpp       MPP's "stellar" payment method, "charge" intent

sorocharge-x402 and sorocharge-mpp depend on sorocharge-signer. sorocharge-signer depends on neither of them, and knows nothing about either protocol’s wire format. That boundary is enforced by what each crate’s public API actually exposes, not just by convention: if you find yourself wanting to import sorocharge-x402 from sorocharge-signer, the abstraction boundary is wrong.

Why one engine, not two

x402 and MPP look different on the wire — one is a bespoke set of HTTP headers, the other is a formal IETF-style authentication scheme — but both resolve to the same underlying operation: a payer signs a SorobanAuthorizationEntry authorizing a SEP-41 transfer(from, to, amount), and a payee (directly, or via a fee-sponsoring facilitator/server) submits it to the network. Building that signing logic twice would mean auditing it twice, and any fix to one copy drifting from the other.

sorocharge-signer is also the only crate with a correctness proof behind it: every function that produces XDR — build_charge_entry, sign_entry — is diffed byte-for-byte in CI against fixtures generated from a pinned @stellar/stellar-sdk version. See Developer setup for how that works. sorocharge-x402 and sorocharge-mpp inherit that proof by construction: they don’t re-implement signing, they call into the crate that’s already been checked against the reference implementation.

What belongs where

ConcernLives in
Building the unsigned SorobanAuthorizationEntrysorocharge-signer
Reconstructing the HashIdPreimage and signing itsorocharge-signer
Verifying asset/payer/amount/recipient/expiry/signaturesorocharge-signer
Checking a simulation shows only the expected balance changesorocharge-signer
x402’s PAYMENT-REQUIRED/PAYMENT-SIGNATURE/PAYMENT-RESPONSE headerssorocharge-x402
x402’s facilitator safety checks (fee-sponsor can’t be tricked)sorocharge-x402
MPP’s WWW-Authenticate: Payment challenge and credential encodingsorocharge-mpp
MPP’s pull/push mode settlementsorocharge-mpp
Wrapping the signed entry in a TransactionEnvelope with the right source accountsorocharge-x402 / sorocharge-mpp (independently — see below)

The last row is deliberate duplication, not an oversight. Building the actual invokeHostFunction operation and Transaction envelope is protocol-adapter work: x402 always places the entry on an all-zeros placeholder source account (the facilitator always sponsors fees), while MPP has to build two different shapes depending on feePayer — a placeholder source for the sponsored case, or a fully-signed, real-sequence-number transaction for the unsponsored case. Sharing that construction would have meant sorocharge-signer growing branches for protocol-specific transaction shapes it has no business knowing about, so each adapter builds its own small version instead.

Non-goals

Neither protocol crate can emit a Delegated (AddressWithDelegates) credential, even though sorocharge-signer supports it. x402’s exact scheme on Stellar explicitly forbids it; MPP’s spec never mentions it. sorocharge-signer keeps the capability because it’s a real CAP-71 credential type a future protocol adapter (or a direct caller) might need — the two shipped adapters simply don’t use it.

x402 protocol mechanics

sorocharge-x402 implements x402 protocol v2’s exact scheme on Stellar — the scheme Stellar’s own x402 implementation (exact on stellar:pubnet / stellar:testnet) uses. Only this scheme and only these two networks are supported; the wire formats below are copied from the spec’s specs/schemes/exact/scheme_exact_stellar.md, not inferred.

The flow

Client                                            Server                 Facilitator
   │  GET /resource                                   │                        │
   ├──────────────────────────────────────────────────>                       │
   │  402 Payment Required                             │                        │
   │  PAYMENT-REQUIRED: <base64 PaymentRequired>        │                        │
   <──────────────────────────────────────────────────┤                        │
   │  (build + sign a charge entry)                     │                        │
   │  GET /resource                                     │                        │
   │  PAYMENT-SIGNATURE: <base64 PaymentPayload>         │                        │
   ├──────────────────────────────────────────────────>│  POST /verify           │
   │                                                     ├───────────────────────>│
   │                                                     │  POST /settle          │
   │                                                     ├───────────────────────>│
   │  200 OK                                            │                        │
   │  PAYMENT-RESPONSE: <base64 SettlementResponse>      │                        │
   <──────────────────────────────────────────────────┤                        │

sorocharge-x402::Facilitator implements the right-hand side (verify/settle) as plain async functions — it does not bind them to real /verify//settle HTTP routes. Wiring that is the resource server’s job, using whatever framework it already runs (see examples/resource_server_x402.rs for a complete reference implementation using axum).

The payload

PaymentPayload.payload for the Stellar exact scheme is not a bare signed authorization entry — it’s the base64-encoded XDR of a full TransactionEnvelope with exactly one invokeHostFunction operation (the SEP-41 transfer call) carrying the signed entry as its sole auth entry:

{
  "transaction": "AAAAAgAAAABriIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1AAAAAAAAAAAAAAAAAAAAA..."
}

The transaction’s source account is the all-zeros account (GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF), fee 0, and sequence number 0 — all three are placeholders the facilitator replaces entirely at settlement, per the spec’s extra.areFeesSponsored: true (currently always true; the spec does not yet define a non-sponsored flow).

Credential type

The client defaults to the legacy (non-AddressV2) credential type for the broadest facilitator compatibility, even though the spec permits AddressV2 too — its own words: “recording-mode simulation still returns the legacy arm as of Protocol 28.” The facilitator accepts both. Neither side will ever emit or accept a delegated-signer credential (sorobanCredentialsAddressWithDelegates); the spec explicitly forbids it.

Facilitator safety

Because the facilitator signs the settlement transaction as fee sponsor, it has to defend itself against a client trying to trick it into paying for, or becoming a party to, an unintended transfer. Before settling, Facilitator checks:

  • the transaction’s source account is not the facilitator’s own address
  • the operation’s source account (if set) is not the facilitator’s own address
  • the from argument of the transfer call is not the facilitator’s own address
  • the facilitator’s address does not appear in any authorization entry
  • a settlement-time re-simulation shows only the expected balance change — not a second transfer, a mint, a burn, a clawback, or a balance change on any other contract (sorocharge_signer::verify_transfer_effects)

Expiration

The authorization entry’s expiration ledger is derived from maxTimeoutSeconds in the payment requirements: currentLedger + ceil(maxTimeoutSeconds / 5). The facilitator enforces this as a hard upper bound, not just a lower one — an entry that’s valid longer than the requirements asked for is rejected (ExpirationExceedsAllowance), exactly like an entry that’s already expired.

MPP protocol mechanics

sorocharge-mpp implements MPP’s draft-stellar-charge-00 — the "stellar" payment method for the "charge" intent, layered on the base draft-httpauth-payment-01 “Payment” HTTP authentication scheme. Only the charge-intent mode is implemented. MPP’s session/channel mode is a different signing primitive entirely (a raw ed25519 signature over a one-way-channel contract’s prepare_commitment output, not a SorobanAuthorizationEntry) and does not exist anywhere in this crate.

The flow

Client                                            Server                 Stellar Network
   │  GET /resource                                   │                        │
   ├──────────────────────────────────────────────────>                       │
   │  402 Payment Required                             │                        │
   │  WWW-Authenticate: Payment id=.., request=..       │                        │
   <──────────────────────────────────────────────────┤                        │
   │  (build + sign a charge entry)                     │                        │
   │  GET /resource                                     │                        │
   │  Authorization: Payment <base64url credential>     │                        │
   ├──────────────────────────────────────────────────>│  verify + submit        │
   │                                                     ├───────────────────────>│
   │  200 OK                                            │                        │
   │  Payment-Receipt: <base64url receipt>               │                        │
   <──────────────────────────────────────────────────┤                        │

Unlike x402, this is a genuine HTTP authentication scheme with WWW-Authenticate challenge and Authorization credential headers — not a bespoke header pair — and it’s MppServer’s job (plain async functions again, not a bound HTTP route) to issue the challenge and settle the credential.

Pull mode vs. push mode

Pull mode (type="transaction", default)Push mode (type="hash", fallback)
Who submitsThe serverThe client, directly to the network
payload carriesBase64 XDR of a transactionA 64-character hex transaction hash
Fee sponsorshipAvailable (feePayer: true)Never available
Server’s jobVerify, (maybe rebuild,) submit, pollFetch via getTransaction, verify shape

Pull mode, sponsored (feePayer: true)

The client signs only the authorization entry, using the legacy credential type — this spec never mentions CAP-71 at all, unlike x402, and permits nothing but sorobanCredentialsAddress. The transaction’s source account is the all-zeros account; the server replaces it, the sequence number, and the fee at settlement, exactly as in x402.

Pull mode, unsponsored (feePayer: false or absent)

The client builds a fully signed, network-ready transaction: a real sequence number (from the payer’s own account), a real fee (derived from a simulation), and timeBounds.maxTime set to the challenge’s expires. The server submits it unmodified.

Push mode (type="hash")

The client has already broadcast the transaction itself. The server fetches it via getTransaction, checks its status is SUCCESS, and checks the same transfer shape pull mode does. The spec requires servers to maintain a set of consumed transaction hashes, so a settled push-mode transaction can’t be presented again on a fresh challenge — MppServer::settle_credential takes an already_consumed flag for exactly this, since the replay set itself is the caller’s to own (see Running an MPP server).

Ledger expiration

Both the client and the server derive a ledger expiration from the challenge’s expires timestamp: currentLedger + ceil(remaining_seconds / 5). The client actually uses half that allowance — a margin discovered on live testnet, where ledgers closing slower than the spec’s 5-second assumption made a fully-allowance-sized expiry fail the server’s own bound check. See sorocharge-mpp/src/client.rs for the exact reasoning.

Server safety

The sponsored flow’s facilitator-safety checks mirror x402’s: the server’s own address must not appear as the from argument or in any authorization entry, the transaction source must be the all-zeros account, and a re-simulation must show only the expected transfer (sorocharge_signer::verify_transfer_effects).

Paying with x402

This is the payer side: you hold a Stellar account, and you want to pay for an x402-protected resource.

Implement Signer

sorocharge-signer::Signer is the only thing you implement yourself — it’s how you control where your private key actually lives:

#![allow(unused)]
fn main() {
use sorocharge_signer::{Address, Signer, SorochargeError};
use ed25519_dalek::{Signer as DalekSigner, SigningKey};
use stellar_xdr::{AccountId, PublicKey, ScAddress, Uint256};

struct KeypairSigner {
    signing_key: SigningKey,
    address: Address,
}

impl Signer for KeypairSigner {
    fn sign_preimage(&self, preimage: &[u8]) -> Result<[u8; 64], SorochargeError> {
        Ok(self.signing_key.sign(preimage).to_bytes())
    }

    fn address(&self) -> Address {
        self.address.clone()
    }
}
}

address() must return an Ed25519 account address (G...) — sign_entry rejects a contract address.

Pay for a resource

#![allow(unused)]
fn main() {
use std::time::Duration;
use sorocharge_x402::X402Client;

let client = X402Client::new(Duration::from_secs(30))?;

// The authorization entry's expiration ledger needs a current ledger to be
// computed from. A getLatestLedger call, done once per payment.
let current_ledger = rpc.get_latest_ledger().await?.sequence;

let response = client
    .get_with_payment(
        "https://api.example.com/premium-data",
        &signer,
        "Test SDF Network ; September 2015",
        current_ledger,
    )
    .await?;

assert_eq!(response.status(), 200);
let body = response.text().await?;
}

get_with_payment does the whole flow: GET, decode the 402’s PAYMENT-REQUIRED challenge, pick the exact-on-Stellar entry matching your network, build and sign a charge entry, wrap it in a transaction, and retry with PAYMENT-SIGNATURE attached. A 200 on the first GET (already paid, or free) is returned as-is without any of that.

What you don’t control

You don’t choose the asset, amount, or recipient — the resource server does, via the 402 challenge, and you have no say beyond paying or not paying. If you need to check the terms before paying (e.g. reject anything over a budget), you’d need to drop to a lower level than get_with_payment — decode the challenge yourself and decide before building the entry. get_with_payment always pays whatever the server asks.

Errors

Every failure is a distinct X402Error variant — see the API reference for the full list. The ones you’re most likely to see as a payer: UnexpectedStatus (the server returned something other than 402 or 200), MissingPaymentRequiredHeader / InvalidPaymentRequired (a malformed or absent challenge), NoAcceptablePaymentRequirements (none of the server’s accepts[] match your network), and anything from Signing (your Signer or the underlying sorocharge-signer call failed).

Running an x402 facilitator

This is the facilitator side: you sponsor transaction fees and settle payments on behalf of a resource server. Facilitator is a set of plain async functions, not a bound HTTP server — you wire /verify, /settle, and /supported to whatever framework you already run. See examples/resource_server_x402.rs for a complete reference implementation using axum.

Set up

#![allow(unused)]
fn main() {
use sorocharge_x402::{Facilitator, FacilitatorConfig};

let facilitator = Facilitator::new(
    &rpc,                                     // &stellar_rpc_client::Client
    &facilitator_signer,                      // your fee-sponsoring key, impl Signer
    "Test SDF Network ; September 2015".to_string(),
    FacilitatorConfig::default(),
);
}

FacilitatorConfig has two fields, both stroop amounts: max_transaction_fee_stroops (default 50_000) is a safety ceiling on the settlement-time simulation-derived fee — a circuit breaker against fee-exhaustion attacks, not a protocol constant, since the spec doesn’t mandate a specific number. inclusion_buffer_stroops (default 100, the spec’s required minimum) is added on top of the simulated resource fee.

Verify, then settle

#![allow(unused)]
fn main() {
let verify_response = facilitator.verify(&verify_request).await?;
if !verify_response.is_valid {
    // respond 402 with verify_response.invalid_reason
}

let settlement = facilitator.settle(&settle_request).await?;
if !settlement.success {
    // respond 402 with settlement.error_reason — "settlement-failed", not
    // "verification-failed": the credential was valid but the on-chain
    // submission didn't go through
}
}

verify and settle share the same validation path internally — settle does not trust a prior verify call, and re-checks everything itself, per the spec’s “/settle MUST perform full verification independently.”

What gets checked

Both calls run, in order: protocol fields (x402Version, scheme, matching network), transaction structure (exactly one invokeHostFunction, a bare transfer(from, to, amount), no sub-invocations), the credential type (legacy or AddressV2 only — never AddressWithDelegates), the facilitator-safety checks (your own address can’t appear as source, operation source, from, or in any authorization entry), signature and expiry verification via sorocharge_signer::verify_entry, and a re-simulation checked with sorocharge_signer::verify_transfer_effects to confirm it moves only the expected balance.

settle additionally rebuilds the transaction with your account as source, derives a fresh fee from a settlement-time simulation (capped by max_transaction_fee_stroops), signs it, submits via sendTransaction, and polls until SUCCESS or FAILED.

#![allow(unused)]
fn main() {
let supported = facilitator.supported();
}

Returns a SupportedResponse naming the exact scheme on whichever network you configured, and your own address as a signer — wire this to a /supported route if your resource servers (or anyone else) need to discover it.

Paying with MPP

This is the payer side: you hold a Stellar account, and you want to pay for an MPP "stellar"/"charge"-protected resource.

Set up

Unlike X402Client, MppClient owns an RPC client reference — the unsponsored flow needs your account’s sequence number and a fee/resource-data simulation before it can build a fully-signed transaction, and pushing those calls onto you would just move them one layer up.

#![allow(unused)]
fn main() {
use std::time::Duration;
use sorocharge_mpp::MppClient;

let client = MppClient::new(&rpc, Duration::from_secs(30))?;
}

Pay for a resource

#![allow(unused)]
fn main() {
let response = client
    .get_with_payment(
        "https://api.example.com/agent-endpoint",
        &signer,                                  // impl sorocharge_signer::Signer
        "Test SDF Network ; September 2015",
    )
    .await?;

assert_eq!(response.status(), 200);
}

get_with_payment decodes whichever WWW-Authenticate: Payment challenge comes back, checks the method is "stellar" and the intent is "charge", builds and signs a charge entry, and retries in whichever header the challenge selected (Authorization by default, or Payment-Authorization if the challenge asked for that). It always uses the legacy credential type — this spec never mentions AddressV2.

The server decides, via methodDetails.feePayer in the challenge — you don’t choose.

  • Sponsored (feePayer: true): the client signs only the authorization entry, on an all-zeros placeholder transaction the server replaces at settlement.
  • Unsponsored (feePayer: false or absent): the client fetches its own account, simulates to derive a fee and fresh Soroban resource data, and signs the entire transaction — a real sequence number, a real fee, timeBounds.maxTime set to the challenge’s expires.

Both paths produce a type="transaction" (pull-mode) credential. MppClient never builds a type="hash" (push-mode) credential — that mode means broadcasting the transaction yourself and presenting the hash, which is a workflow MppClient doesn’t drive for you.

The expiration margin

The authorization entry’s expiration is set to half the spec’s allowed ledger range (currentLedger + ceil(remaining_seconds / 5), then halved), not the full allowance. This was found on live testnet: the server independently re-derives its own bound from its own clock, and when ledgers close slower than the spec’s 5-second assumption, a full-allowance expiry set by the client can fail the server’s check even though nothing was done wrong. Halving it costs a shorter authorization window — which is also less exposure, not just a workaround.

Errors

See the API reference for the full list. As a payer you’re most likely to see: MissingChallenge / InvalidChallenge (no challenge, or one that doesn’t parse), UnsupportedMethod / UnsupportedIntent (not "stellar"/"charge" — you’ve pointed this client at something else), ChallengeExpired, and anything from Signing.

Running an MPP server

This is the server side: you issue "stellar"/"charge" challenges and settle whatever credentials come back. Like Facilitator, MppServer is plain functions, not a bound HTTP server — see examples/resource_server_mpp.rs for a complete reference implementation.

What MppServer deliberately doesn’t own

draft-stellar-charge-00 scopes itself to “the Stellar-specific methodDetails, payload, and verification procedures for the stellar payment method” — challenge storage, HMAC challenge-binding, and replay protection belong to the generic base draft-httpauth-payment-01 scheme, which isn’t Stellar-specific and isn’t this crate’s job. Concretely:

  • You track which ChargeRequest a challenge id was issued for. settle_credential takes the original ChargeRequest as a parameter — it doesn’t remember what it issued.
  • You track consumed push-mode transaction hashes. settle_credential takes an already_consumed: bool you compute from your own store before calling it, and you mark the hash consumed yourself after a successful return.

Issue a challenge

#![allow(unused)]
fn main() {
use chrono::{Duration, Utc};
use sorocharge_mpp::{ChargeRequest, MethodDetails, MppServer, MppServerConfig};

let server = MppServer::new(
    &rpc,
    &server_signer,                           // your fee-sponsoring key, for sponsored charges
    "Test SDF Network ; September 2015".to_string(),
    MppServerConfig::default(),
);

let charge = ChargeRequest {
    amount: "1000000".to_string(),
    currency: asset_contract.to_string(),
    recipient: server_signer.address().to_string(),
    description: Some("API access fee".to_string()),
    external_id: None,
    method_details: MethodDetails {
        network: "stellar:testnet".to_string(),
        fee_payer: true,
    },
};
let expires = Utc::now() + Duration::minutes(5);
let header = server.build_challenge_header(&challenge_id, &realm, &charge, expires)?;

// store (challenge_id -> (charge, expires)) yourself, then:
// respond 402 with `WWW-Authenticate: {header}`
}

Settle a credential

#![allow(unused)]
fn main() {
let already_consumed = /* only relevant for push mode: your own hash-store lookup */;

let receipt = server
    .settle_credential(&credential, &stored_charge, Utc::now(), already_consumed)
    .await?;

// respond 200 with `Payment-Receipt: {base64url(receipt)}`
}

settle_credential branches on credential.payload:

  • Pull mode: decodes the transaction, checks it’s a bare SEP-41 transfer using the legacy credential type (the only type this spec permits), checks the authorization entry’s expiration doesn’t exceed the challenge’s allowance, verifies signature and amount/asset/recipient via sorocharge_signer::verify_entry, re-simulates and checks only the expected balance moves (sorocharge_signer::verify_transfer_effects), then — sponsored only — rebuilds with your account as source, signs, submits, and polls. Unsponsored transactions are submitted as received, unmodified.
  • Push mode: fetches the transaction via getTransaction, checks its status is SUCCESS and the transfer shape matches, and refuses outright if the challenge specified feePayer: true (push mode can never be fee-sponsored).

Errors

See the API reference for the full list, including PushModeForbidsFeePayer, ServerAddressConflict, HashAlreadyConsumed, and the distinction between VerificationFailed (the credential itself didn’t check out) and SettlementFailed (it checked out, but the on-chain submission didn’t go through).

Developer setup

Build and test

cargo build --workspace --all-targets
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check

Rust 1.98.1 is the pinned toolchain (workspace.package.rust-version in the root Cargo.toml). CI runs all four of the above on every push to main and every pull request.

Reproducing the golden vectors

Every function in sorocharge-signer that produces XDR is tested against fixtures generated by a pinned @stellar/stellar-sdk version — not committed blindly, regenerated and diffed in CI:

cd tests/golden_vectors
npm ci
node generate.mjs
git diff --exit-code fixtures   # should report no changes

If that diff is non-empty, either the pinned SDK version changed (it shouldn’t have — it’s an exact pin in package.json) or sorocharge-signer’s output genuinely changed. Either way, that’s a signal to stop and look, not to update the fixtures and move on.

Running the examples against testnet

Every example takes its signing key from an environment variable — never hardcoded, never committed. Fund a testnet account at friendbot and export its S... secret seed.

The fastest way to see the whole system work end to end needs no external resource server at all — verify_and_settle plays both the payer and the facilitator itself:

export SOROCHARGE_TESTNET_PAYER_SECRET_KEY=S...
export SOROCHARGE_TESTNET_FACILITATOR_SECRET_KEY=S...
export SOROCHARGE_TESTNET_ASSET_CONTRACT=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC  # testnet native XLM SAC
cargo run --example verify_and_settle -p sorocharge-x402

That asset contract is testnet’s native-XLM Stellar Asset Contract — no trustline needed, since friendbot already funds the account in XLM.

To see the actual HTTP flow, run a resource server and a client in separate shells:

# shell 1
export SOROCHARGE_TESTNET_FACILITATOR_SECRET_KEY=S...
export SOROCHARGE_TESTNET_ASSET_CONTRACT=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
cargo run --example resource_server_x402 -p sorocharge-x402

# shell 2
export SOROCHARGE_TESTNET_SECRET_KEY=S...     # a different, funded account
export SOROCHARGE_X402_RESOURCE_URL=http://127.0.0.1:8402/resource
cargo run --example sign_x402_payment -p sorocharge-x402

The MPP equivalents are resource_server_mpp and sign_mpp_charge in sorocharge-mpp’s examples/, with SOROCHARGE_MPP_FEE_PAYER=false on the server to exercise the unsponsored flow instead of the sponsored default.

Building this documentation site

cargo install mdbook   # if you don't have it
cd docs
mdbook build           # writes docs/book/
mdbook serve           # or, serve it locally with live reload

sorocharge-signer

The shared engine. Builds, signs, and verifies a SorobanAuthorizationEntry for a single SEP-41 transfer(from, to, amount), across every CAP-71 credential shape. Knows nothing about x402, MPP, or HTTP.

Types

Address

#![allow(unused)]
fn main() {
pub type Address = stellar_xdr::ScAddress;
}

A direct alias, not a wrapper — the network’s own address encoding is already the right shape.

ChargeParams

#![allow(unused)]
fn main() {
pub struct ChargeParams {
    pub asset_contract: Address,  // the SEP-41 SAC contract address
    pub amount: i128,             // base units — never a float
    pub payer: Address,
    pub recipient: Address,
    pub valid_until_ledger: u32,  // a ledger number — never wall-clock time
}
}

CredentialKind

#![allow(unused)]
fn main() {
pub enum CredentialKind {
    Legacy,
    AddressV2,
    Delegated { signers: Vec<Address> },
}
}

Mirrors SorobanCredentialsType except for SOROBAN_CREDENTIALS_SOURCE_ACCOUNT, which has no signature to build or verify — every charge here is authorized by an explicit Address, never implicitly by the transaction’s source account.

UnsignedEntry / SignedEntry

Opaque wrappers around a SorobanAuthorizationEntry, before and after it carries a signature. Both expose as_xdr() -> &SorobanAuthorizationEntry for serializing to XDR or attaching to an InvokeHostFunction operation. SignedEntry additionally has from_xdr(SorobanAuthorizationEntry) -> Self, for wrapping an entry that already carries a signature — the shape a facilitator or server receives over the wire, as opposed to one this process signed itself.

Signer

#![allow(unused)]
fn main() {
pub trait Signer: Send + Sync {
    fn sign_preimage(&self, preimage: &[u8]) -> Result<[u8; 64], SorochargeError>;
    fn address(&self) -> Address;
}
}

The only abstraction you implement. sign_preimage signs a 32-byte digest and returns a raw 64-byte ed25519 signature; address names the Ed25519 account (G...) it signs for — a contract address here is an error at sign time. Send + Sync so a Signer can be held across an .await inside an async HTTP handler.

Functions

parse_address

#![allow(unused)]
fn main() {
pub fn parse_address(strkey: &str) -> Result<Address, SorochargeError>;
}

Decodes a SEP-23 strkey (G..., C..., M...) into an Address. Any decode failure — bad length, broken checksum, or a well-formed strkey of a type that is not an address — becomes InvalidAddress carrying the offending string, so JSON-facing callers get this library’s own actionable variant instead of a bare parse error.

build_charge_entry

#![allow(unused)]
fn main() {
pub fn build_charge_entry(
    params: &ChargeParams,
    credential: CredentialKind,
) -> Result<UnsignedEntry, SorochargeError>;
}

Builds the unsigned entry. The nonce is generated internally with a fresh source of randomness on every call — it exists to make each authorization unique, not to encode caller state, so it isn’t a parameter. For Delegated, the signers are sorted by their XDR-encoded address bytes ascending and checked for duplicates, per CAP-71-01.

sign_entry

#![allow(unused)]
fn main() {
pub fn sign_entry(
    entry: UnsignedEntry,
    signer: &dyn Signer,
    network_passphrase: &str,
) -> Result<SignedEntry, SorochargeError>;
}

Reconstructs the correct HashIdPreimage for the entry’s actual credential type — the legacy, non-address-bound preimage for Legacy, the CAP-71 address-bound preimage for AddressV2 and Delegated — signs its sha256 digest, and writes the resulting {public_key, signature} vector onto whichever credential node (top-level address, or one delegate) matches signer.address(). network_passphrase is required because the signing payload is network-bound: sha256(network_passphrase) is baked into what gets signed, so there is no default network to silently assume.

A Delegated entry needing more than one signer’s signature needs one sign_entry call per signer, each targeting its own address — accumulating multiple delegates’ signatures across separate calls is out of scope for this function’s single-shot UnsignedEntry -> SignedEntry shape.

verify_entry

#![allow(unused)]
fn main() {
pub fn verify_entry(
    entry: &SignedEntry,
    expected: &ChargeParams,
    current_ledger: u32,
    network_passphrase: &str,
) -> Result<(), SorochargeError>;
}

Fails closed, in this order:

  1. ExpiredEntry — valid_until_ledger has already passed current_ledger.
  2. ExpirationExceedsAllowance — the entry’s own expiration is later than expected.valid_until_ledger. Not expired, but valid for longer than the caller said it would accept.
  3. UnexpectedInvocationShape — not a single, bare transfer(from, to, amount) call.
  4. AssetMismatch
  5. PayerMismatch — the authorizing address (which must also be the invocation’s from) doesn’t match expected.payer.
  6. AmountNonPositive — either side’s amount is zero or negative; a SEP-41 transfer must move a positive amount. Checked before AmountMismatch so a non-positive amount reports itself instead of masquerading as a mismatch.
  7. AmountMismatch
  8. RecipientMismatch
  9. InvalidSignature — no signable node carries a valid ed25519 signature over the reconstructed preimage. For Delegated, this proves at least one attached signature is genuine; it cannot know the target account’s own multisig/threshold policy.

verify_delegated_threshold

#![allow(unused)]
fn main() {
pub fn verify_delegated_threshold(
    entry: &SignedEntry,
    _expected: &ChargeParams,
    _current_ledger: u32,
    network_passphrase: &str,
    signers_with_weights: &[(Address, u32)],
    threshold: u32,
) -> Result<(), SorochargeError>;
}

Checks that the valid signatures attached to a Delegated entry’s signable nodes meet the account’s actual threshold policy — stricter than verify_entry, which only proves at least one attached signature is genuine. _expected and _current_ledger are accepted for signature symmetry with verify_entry and are not consulted yet. Unused by the x402 and MPP adapters today (x402 forbids AddressWithDelegates; MPP accepts only legacy Address credentials for pull mode); it exists for direct sorocharge-signer callers using Delegated credentials.

verify_transfer_effects

#![allow(unused)]
fn main() {
pub fn verify_transfer_effects(
    events: &[String],           // base64 DiagnosticEvent XDR, from a simulation
    asset_contract: &Address,
    payer: &Address,
    recipient: &Address,
    amount: i128,
) -> Result<(), SorochargeError>;
}

Checks a simulation’s events show exactly the expected SEP-41 transfer — no second transfer, no transfer to someone else, no mint/burn/clawback, no balance event from a different contract. The transfer shape checked ([transfer, from, to] topics, i128 data) is the live Stellar host’s actual output, verified against a real testnet transaction — not the CAP text, which the host doesn’t always match exactly.

Errors

SorochargeError — every variant is named for one specific, actionable failure; no bare String, no catch-all:

VariantMeaning
InvalidAddress { strkey }A strkey failed to decode into an ScAddress.
UnsupportedCredentialTypeThe XDR credential shape matches no variant this library signs or verifies.
EmptyDelegateSignersDelegated was constructed with zero signers.
DuplicateDelegateSignerThe same delegate address appeared more than once.
ExpiredEntry { valid_until_ledger, current_ledger }Already expired.
ExpirationExceedsAllowance { signature_expiration_ledger, allowed_until_ledger }Valid longer than the caller allowed.
UnexpectedInvocationShapeNot a bare SEP-41 transfer.
AssetMismatch / PayerMismatch / AmountMismatch / RecipientMismatchField doesn’t match.
AmountNonPositiveThe amount is zero or negative — invalid for a SEP-41 transfer.
InvalidSignatureNo genuine signature found.
NoMatchingCredentialNodeA signer’s address matches no node in the entry being signed.
SimulationEventsMalformed { reason }An event didn’t decode, or lacks expected topics/data.
UnexpectedBalanceChange { reason }The simulation moved something other than the expected payment.
ExpectedTransferMissingThe simulation doesn’t show the expected transfer at all.
SigningFailed { reason }The Signer implementation returned an error, or isn’t an Ed25519 account.
XdrEncodingFailed { reason }A value didn’t fit its XDR-constrained shape.

sorocharge-x402

x402 protocol v2’s exact scheme on Stellar. Wire types and shapes are copied verbatim from x402-foundation/x402’s specs/schemes/exact/scheme_exact_stellar.md and specs/x402-specification-v2.md.

Client

X402Client

#![allow(unused)]
fn main() {
impl X402Client {
    pub fn new(request_timeout: std::time::Duration) -> Result<Self, X402Error>;

    pub async fn get_with_payment(
        &self,
        url: &str,
        signer: &dyn sorocharge_signer::Signer,
        network_passphrase: &str,
        current_ledger: u32,
    ) -> Result<reqwest::Response, X402Error>;
}
}

See Paying with x402.

Facilitator

Facilitator

#![allow(unused)]
fn main() {
impl<'a> Facilitator<'a> {
    pub fn new(
        rpc: &'a stellar_rpc_client::Client,
        signer: &'a dyn sorocharge_signer::Signer,
        network_passphrase: String,
        config: FacilitatorConfig,
    ) -> Self;

    pub async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, X402Error>;
    pub async fn settle(&self, request: &SettleRequest) -> Result<SettlementResponse, X402Error>;
    pub fn supported(&self) -> SupportedResponse;
}
}
#![allow(unused)]
fn main() {
pub struct FacilitatorConfig {
    pub max_transaction_fee_stroops: i64,  // default 50_000
    pub inclusion_buffer_stroops: i64,     // default 100 (the spec's required minimum)
}
}

SettleRequest is a type alias for VerifyRequest — the spec gives /settle the identical request shape as /verify. See Running an x402 facilitator.

Wire types

All of these derive Serialize/Deserialize with camelCase field names matching the spec exactly.

#![allow(unused)]
fn main() {
pub struct ResourceInfo { pub url: String, pub description: Option<String>, pub mime_type: Option<String> }

pub struct StellarExtra { pub are_fees_sponsored: bool }

pub struct PaymentRequirements {
    pub scheme: String,          // always "exact"
    pub network: String,         // "stellar:pubnet" | "stellar:testnet"
    pub amount: String,          // decimal string, base units
    pub asset: String,           // SEP-41 SAC contract (C...)
    pub pay_to: String,          // recipient account (G...)
    pub max_timeout_seconds: u64,
    pub extra: StellarExtra,
}

pub struct PaymentRequired {
    pub x402_version: u32,       // 2
    pub error: Option<String>,
    pub resource: ResourceInfo,
    pub accepts: Vec<PaymentRequirements>,
    pub extensions: serde_json::Map<String, serde_json::Value>,
}

pub struct StellarPayload { pub transaction: String }  // base64 TransactionEnvelope

pub struct PaymentPayload {
    pub x402_version: u32,
    pub resource: Option<ResourceInfo>,
    pub accepted: PaymentRequirements,
    pub payload: StellarPayload,
    pub extensions: serde_json::Map<String, serde_json::Value>,
}

pub struct VerifyRequest {
    pub x402_version: u32,
    pub payment_payload: PaymentPayload,
    pub payment_requirements: PaymentRequirements,
}

pub struct VerifyResponse {
    pub is_valid: bool,
    pub invalid_reason: Option<String>,
    pub payer: Option<String>,
}

pub struct SettlementResponse {
    pub success: bool,
    pub error_reason: Option<String>,
    pub payer: Option<String>,
    pub transaction: String,     // tx hash, or "" if none broadcast
    pub network: String,
    pub amount: Option<String>,
}

pub struct SupportedKind { pub x402_version: u32, pub scheme: String, pub network: String }
pub struct SupportedResponse {
    pub kinds: Vec<SupportedKind>,
    pub extensions: Vec<String>,
    pub signers: std::collections::BTreeMap<String, Vec<String>>,  // "stellar:*" -> addresses
}
}

Headers

HeaderDirectionContent
PAYMENT-REQUIREDServer → ClientBase64 JSON PaymentRequired
PAYMENT-SIGNATUREClient → ServerBase64 JSON PaymentPayload
PAYMENT-RESPONSEServer → ClientBase64 JSON SettlementResponse

X402Error

VariantMeaning
HttpRequestFailed { reason }The HTTP request itself failed.
UnexpectedStatus { status }Neither 402 nor 200.
MissingPaymentRequiredHeaderA 402 carried no PAYMENT-REQUIRED header.
InvalidPaymentRequired { reason }The header didn’t base64/JSON-decode.
NoAcceptablePaymentRequirementsNo accepts[] entry matches scheme + network.
UnsupportedNetwork { network }No CAIP-2 mapping for this network.
Signing(SorochargeError)sorocharge-signer failed to build or sign.
XdrEncodingFailed { reason } / XdrDecodingFailed { reason }XDR (de)serialization failed.
InvalidPaymentPayload { reason }The credential’s JSON didn’t match the expected shape.
UnexpectedTransactionShape { reason }Not a bare SEP-41 transfer — wrong op count, function, args, or a smuggled sub-invocation.
ForbiddenCredentialTypesorobanCredentialsSourceAccount or sorobanCredentialsAddressWithDelegates — the spec forbids both for this scheme.
FacilitatorAddressConflictThe facilitator’s own address appeared where the safety checks forbid it.
RpcFailed { reason }A Stellar RPC call failed.
FeeExceedsCeiling { fee_stroops, ceiling_stroops }The settlement-derived fee exceeded max_transaction_fee_stroops.
SettlementFailed { reason }Verified, but on-chain submission failed.

sorocharge-mpp

MPP’s draft-stellar-charge-00, the "stellar" payment method for the "charge" intent. Wire shapes are copied verbatim from tempoxyz/mpp-specs (draft-stellar-charge-00, draft-payment-intent-charge-00, draft-httpauth-payment-01).

Client

#![allow(unused)]
fn main() {
impl<'a> MppClient<'a> {
    pub fn new(
        rpc: &'a stellar_rpc_client::Client,
        request_timeout: std::time::Duration,
    ) -> Result<Self, MppError>;

    pub async fn get_with_payment(
        &self,
        url: &str,
        signer: &dyn sorocharge_signer::Signer,
        network_passphrase: &str,
    ) -> Result<reqwest::Response, MppError>;
}
}

See Paying with MPP.

Server

#![allow(unused)]
fn main() {
impl<'a> MppServer<'a> {
    pub fn new(
        rpc: &'a stellar_rpc_client::Client,
        signer: &'a dyn sorocharge_signer::Signer,
        network_passphrase: String,
        config: MppServerConfig,
    ) -> Self;

    pub fn build_challenge_header(
        &self,
        id: &str,
        realm: &str,
        request: &ChargeRequest,
        expires: chrono::DateTime<chrono::Utc>,
    ) -> Result<String, MppError>;

    pub async fn settle_credential(
        &self,
        credential: &Credential,
        expected: &ChargeRequest,
        current_time: chrono::DateTime<chrono::Utc>,
        already_consumed: bool,
    ) -> Result<Receipt, MppError>;
}
}
#![allow(unused)]
fn main() {
pub struct MppServerConfig {
    pub max_transaction_fee_stroops: i64,  // default 50_000 — not spec-mandated, this
                                            // crate's own conservative choice
    pub inclusion_buffer_stroops: i64,     // default 100
}
}

See Running an MPP server for what settle_credential deliberately doesn’t own (challenge storage, replay protection) and why.

Wire types

#![allow(unused)]
fn main() {
pub struct MethodDetails {
    pub network: String,   // "stellar:pubnet" | "stellar:testnet"
    pub fee_payer: bool,   // default false
}

pub struct ChargeRequest {
    pub amount: String,                  // decimal string, base units
    pub currency: String,                // SEP-41 SAC contract (C...)
    pub recipient: String,               // account (G...)
    pub description: Option<String>,
    pub external_id: Option<String>,
    pub method_details: MethodDetails,
}

pub struct Challenge {
    pub id: String,
    pub realm: String,
    pub method: String,                  // "stellar"
    pub intent: String,                  // "charge"
    pub request: String,                 // base64url-encoded ChargeRequest, still encoded
    pub description: Option<String>,
    pub opaque: Option<String>,
    pub digest: Option<String>,
    pub expires: Option<String>,         // RFC 3339
    pub header: Option<String>,          // "Payment-Authorization" if the challenge selected it
}

pub enum Payload {
    Transaction { transaction: String },  // base64 TransactionEnvelope — pull mode
    Hash { hash: String },                // 64-char hex tx hash — push mode
}

pub struct Credential {
    pub challenge: Challenge,
    pub payload: Payload,
    pub source: Option<String>,          // e.g. "did:pkh:stellar:testnet:G..."
}

pub struct Receipt {
    pub method: String,                  // "stellar"
    pub reference: String,               // settled tx hash
    pub status: String,                  // "success"
    pub timestamp: String,               // RFC 3339
    pub external_id: Option<String>,
}
}

Headers

HeaderDirectionContent
WWW-AuthenticateServer → ClientPayment id="...", realm="...", method="stellar", intent="charge", request="...", expires="..."
Authorization (or Payment-Authorization)Client → ServerPayment <base64url Credential>
Payment-ReceiptServer → Client<base64url Receipt>

MppError

VariantMeaning
HttpRequestFailed { reason }The HTTP request itself failed.
UnexpectedStatus { status }Neither 402 nor 200.
MissingChallengeNo WWW-Authenticate: Payment on a 402.
InvalidChallenge { reason }Auth-params didn’t parse, or a required one is missing.
UnsupportedMethod { method } / UnsupportedIntent { intent }Not "stellar" / "charge".
InvalidChargeRequest { reason }The request field didn’t decode to a valid charge request.
UnsupportedNetwork { network }No CAIP-2 mapping.
ChallengeExpiredPast the expires timestamp.
Signing(SorochargeError)sorocharge-signer failed.
XdrEncodingFailed { reason } / XdrDecodingFailed { reason }XDR (de)serialization failed.
InvalidCredential { reason }The credential’s JSON didn’t match the expected shape.
UnexpectedTransactionShape { reason }Not a bare SEP-41 transfer.
ForbiddenCredentialTypeAnything other than legacy sorobanCredentialsAddress in pull mode.
PushModeForbidsFeePayerA type="hash" credential against a feePayer: true challenge.
ServerAddressConflictThe server’s own address appeared where the safety checks forbid it.
RpcFailed { reason }A Stellar RPC call failed.
HashAlreadyConsumedA push-mode hash was already settled once.
VerificationFailed { reason }The credential failed a verification check.
SettlementFailed { reason }Verified, but on-chain submission failed.

Contributing

Scoped issues are posted for each sprint cycle — see the open issues for what’s currently available, organized by area: core protocol correctness, bindings and API surface, infrastructure and process, publishing and documentation, and contributor-facing examples. Each issue has a Summary, Acceptance Criteria, and a Tech Stack line, so you can tell what it needs before picking it up.

Before you start

  • Read CLAUDE.md at the repo root. It’s this project’s actual build brief, and the coding standards in it (no unwrap()/expect() outside tests, no floats for amounts or ledger numbers, every public error variant specific enough to act on) are enforced, not aspirational.
  • Run the full check suite locally before opening a pull request:
    cargo test --workspace
    cargo clippy --workspace --all-targets -- -D warnings
    cargo fmt --all -- --check
    
  • If your change touches sorocharge-signer’s XDR-producing functions, regenerate and diff the golden vectors (see Developer setup) — CI will catch a mismatch, but it’s faster to catch it yourself first.

Git workflow

  • One commit per logical unit — one function, one type, one test block. Not everything bundled into a single “implement feature” commit.
  • Conventional commits: type(scope): description, lowercase, imperative (fix(signer): ..., feat(mpp): ..., docs: ...).
  • Push after every commit.
  • Never force-push or rewrite already-pushed history.
  • Never commit a secret, a test seed phrase, or a funded testnet key — every example reads its signing key from an environment variable for exactly this reason.

Verification standard

This library signs and verifies things that move money. “Looks right” is not the bar — byte-for-byte correctness against the reference implementation is. If you’re adding to sorocharge-signer, a golden-vector test diffing your XDR output against a fixture generated from the pinned @stellar/stellar-sdk version is not optional. If you’re adding a check that reads simulation events or on-chain state, prefer building the test fixture from a real transaction over a hand-constructed one — see crates/sorocharge-signer/src/effects.rs’s tests for the pattern: a real transaction’s diagnostic events, with specific fields mutated to construct each rejection case.

Questions

Open an issue, or comment on the one you’re planning to pick up before starting substantial work — especially for anything marked as a scope decision (see e.g. sorocharge-php#5, which asks for a design proposal before any code).