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-signerbuilds, signs, and verifies aSorobanAuthorizationEntryfor a SEP-41 transfer, across every CAP-71 credential shape: legacySorobanAddressCredentials, CAP-71 V2 (AddressV2), and delegated signers (AddressWithDelegates). It knows nothing about HTTP, x402, or MPP.sorocharge-x402implements the payer client and facilitator sides of x402 protocol v2’sexactscheme on Stellar.sorocharge-mppimplements the payer client and server sides of MPP’sdraft-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-channelcontract’sprepare_commitmentoutput — a different primitive entirely, with noSorobanAuthorizationEntryinvolved. 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
Signerthe caller already controls. No key storage, no HSM integration, no wallet. - Not an HTTP server.
sorocharge-x402’sFacilitatorandsorocharge-mpp’sMppServerare plain functions over already-deserialized request types — wiring them to real/verify,/settle, orWWW-AuthenticateHTTP handling is the embedding application’s job, using whatever web framework it already runs.
Where to go next
- New to the protocols? Start with x402 protocol mechanics or MPP protocol mechanics.
- Integrating as a payer or a payee? Jump straight to the guides.
- Looking for exact function signatures? See the API reference.
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
| Concern | Lives in |
|---|---|
Building the unsigned SorobanAuthorizationEntry | sorocharge-signer |
Reconstructing the HashIdPreimage and signing it | sorocharge-signer |
| Verifying asset/payer/amount/recipient/expiry/signature | sorocharge-signer |
| Checking a simulation shows only the expected balance change | sorocharge-signer |
x402’s PAYMENT-REQUIRED/PAYMENT-SIGNATURE/PAYMENT-RESPONSE headers | sorocharge-x402 |
| x402’s facilitator safety checks (fee-sponsor can’t be tricked) | sorocharge-x402 |
MPP’s WWW-Authenticate: Payment challenge and credential encoding | sorocharge-mpp |
| MPP’s pull/push mode settlement | sorocharge-mpp |
Wrapping the signed entry in a TransactionEnvelope with the right source account | sorocharge-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
fromargument of thetransfercall 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 submits | The server | The client, directly to the network |
payload carries | Base64 XDR of a transaction | A 64-character hex transaction hash |
| Fee sponsorship | Available (feePayer: true) | Never available |
| Server’s job | Verify, (maybe rebuild,) submit, poll | Fetch 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.
Advertise what you support
#![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.
Sponsored vs. unsponsored
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: falseor 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.maxTimeset to the challenge’sexpires.
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
ChargeRequesta challenge id was issued for.settle_credentialtakes the originalChargeRequestas a parameter — it doesn’t remember what it issued. - You track consumed push-mode transaction hashes.
settle_credentialtakes analready_consumed: boolyou 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 isSUCCESSand the transfer shape matches, and refuses outright if the challenge specifiedfeePayer: 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:
ExpiredEntry—valid_until_ledgerhas already passedcurrent_ledger.ExpirationExceedsAllowance— the entry’s own expiration is later thanexpected.valid_until_ledger. Not expired, but valid for longer than the caller said it would accept.UnexpectedInvocationShape— not a single, baretransfer(from, to, amount)call.AssetMismatchPayerMismatch— the authorizing address (which must also be the invocation’sfrom) doesn’t matchexpected.payer.AmountNonPositive— either side’s amount is zero or negative; a SEP-41 transfer must move a positive amount. Checked beforeAmountMismatchso a non-positive amount reports itself instead of masquerading as a mismatch.AmountMismatchRecipientMismatchInvalidSignature— no signable node carries a valid ed25519 signature over the reconstructed preimage. ForDelegated, 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:
| Variant | Meaning |
|---|---|
InvalidAddress { strkey } | A strkey failed to decode into an ScAddress. |
UnsupportedCredentialType | The XDR credential shape matches no variant this library signs or verifies. |
EmptyDelegateSigners | Delegated was constructed with zero signers. |
DuplicateDelegateSigner | The 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. |
UnexpectedInvocationShape | Not a bare SEP-41 transfer. |
AssetMismatch / PayerMismatch / AmountMismatch / RecipientMismatch | Field doesn’t match. |
AmountNonPositive | The amount is zero or negative — invalid for a SEP-41 transfer. |
InvalidSignature | No genuine signature found. |
NoMatchingCredentialNode | A 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. |
ExpectedTransferMissing | The 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
| Header | Direction | Content |
|---|---|---|
PAYMENT-REQUIRED | Server → Client | Base64 JSON PaymentRequired |
PAYMENT-SIGNATURE | Client → Server | Base64 JSON PaymentPayload |
PAYMENT-RESPONSE | Server → Client | Base64 JSON SettlementResponse |
X402Error
| Variant | Meaning |
|---|---|
HttpRequestFailed { reason } | The HTTP request itself failed. |
UnexpectedStatus { status } | Neither 402 nor 200. |
MissingPaymentRequiredHeader | A 402 carried no PAYMENT-REQUIRED header. |
InvalidPaymentRequired { reason } | The header didn’t base64/JSON-decode. |
NoAcceptablePaymentRequirements | No 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. |
ForbiddenCredentialType | sorobanCredentialsSourceAccount or sorobanCredentialsAddressWithDelegates — the spec forbids both for this scheme. |
FacilitatorAddressConflict | The 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
| Header | Direction | Content |
|---|---|---|
WWW-Authenticate | Server → Client | Payment id="...", realm="...", method="stellar", intent="charge", request="...", expires="..." |
Authorization (or Payment-Authorization) | Client → Server | Payment <base64url Credential> |
Payment-Receipt | Server → Client | <base64url Receipt> |
MppError
| Variant | Meaning |
|---|---|
HttpRequestFailed { reason } | The HTTP request itself failed. |
UnexpectedStatus { status } | Neither 402 nor 200. |
MissingChallenge | No 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. |
ChallengeExpired | Past 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. |
ForbiddenCredentialType | Anything other than legacy sorobanCredentialsAddress in pull mode. |
PushModeForbidsFeePayer | A type="hash" credential against a feePayer: true challenge. |
ServerAddressConflict | The server’s own address appeared where the safety checks forbid it. |
RpcFailed { reason } | A Stellar RPC call failed. |
HashAlreadyConsumed | A 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.mdat the repo root. It’s this project’s actual build brief, and the coding standards in it (nounwrap()/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).