upto Scheme for Stellar
The Stellar implementation of the x402 upto payment scheme: authorize a ceiling, settle actual usage.
Versions supported
- ❌
v1- we don't plan to support v1 for now. - ✅
v2
Supported Networks
This spec uses CAIP-2 identifiers:
stellar:pubnetStellar mainnetstellar:testnetStellar testnet
Summary
The x402 upto on Stellar authorizes a transfer of up to a maximum amount of a
SEP-41 token. The client signs an
authorization for a ceiling; the actual amount charged is determined later,
after a resource has been served, based on real consumption (tokens
generated, bytes transferred, compute used).
Note Scope: This spec covers SEP-41-compliant Soroban tokens only. Classic Stellar assets are not supported. Payers may be either G-accounts (classic Stellar accounts) or C-accounts (Soroban smart-wallet/custom-account contracts). Both use signed
SorobanAuthorizationEntryvalues on the wire. A G-account signer produces the protocol-defined Stellar-account signature; a C-account wallet produces whatever signature value its own__check_authimplementation expects (passkey assertions, multisig proofs, session-key proofs, and so on). There is intentionally no universal C-account keypair algorithm: clients integrate a wallet-provided auth-entry signer instead.payTomay likewise be any valid StellarAddressaccepted by the selected SEP-41 token.
“C-account support” means the scheme and wire format do not assume a specific
smart-wallet implementation. A particular C-account is usable only when its
wallet can sign the authorization entry and its __check_auth policy accepts
the settle + approve invocation tree defined here. An arbitrary account
contract may deliberately reject that tree; no protocol can bypass the
account's own authorization policy.
Settlement is handled by a single, minimal, stateless contract
(UptoSettlement), deployed once per network. It holds no funds at any
point there is no escrow or deposit step and stores no per-request
state on-chain. Every authorization is fully self-contained in one signed
authorization entry, and is settled in exactly one transaction containing
exactly one operation.
Example Use Cases
- Paying for LLM token generation (charge per token generated)
- Bandwidth or data transfer metering (charge per byte transferred in a single request)
- Dynamic compute pricing (charge based on actual resources consumed)
Why UptoSettlement exists, and why it needs no storage
The core difficulty upto has to solve: the client must sign something
before the amount to be charged is known, and that signature must still
bind everything else that matters recipient, asset, ceiling, timing
tightly enough that nothing else about the payment can be tampered with
afterward.
Soroban's authorization model checks a signed invocation's arguments
exactly against what's submitted on-chain. A contract call signed with a
fixed amount therefore can't later be settled for a different amount
the signature simply wouldn't match. So a plain, direct token transfer
can't be pre-signed for upto; amount has to be excluded from what's
signed, and Soroban's require_auth_for_args makes that possible: it lets
a contract author authorize against a custom argument tuple chosen by
the contract, rather than the literal argument list of the function that
was actually invoked. UptoSettlement.settle uses this to authorize
against (payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke) everything except amount.
That still leaves a mechanical problem: once amount is free to vary,
nothing has actually authorized a token to move at all SEP-41 token
transfers themselves still require a matching signed invocation with fixed
arguments, same as any other Soroban call. UptoSettlement resolves this
using the standard SEP-41 allowance pattern (approve / transfer_from):
- The client signs an authorization entry whose root is the
settlecall above, withtoken.approve(from, UptoSettlement, maxAmount, expirationLedger)as a fixed-argument sub-invocation this is still fine to pre-sign, because both the ceiling and the expiration, unlike the eventual charge, are chosen and known up front by the client itself (see §UptoSettlementContract Interface for whyexpirationLedgerspecifically must be client-chosen rather than computed later). - Inside
settle(), the contract grants itself that allowance (satisfied by the pre-signed sub-invocation), then callstransfer_from(self, from, payTo, amount). This leg requires no separate signature at all, because the contract itself is both the invoker and thespendera contract authorizing its own call is satisfied automatically. - If the client opts in (
autoRevoke = true), a second pre-signed, fixed-argument sub-invocationtoken.approve(from, UptoSettlement, 0, 0)lets the contract zero out any unused allowance in the same atomic call, whenamount < maxAmount.
Because steps 1–3 all happen inside one transaction, there is no
inter-transaction window between approval and transfer. With
autoRevoke = true, no unused allowance survives the settlement. With
autoRevoke = false, the unused remainder remains until
expirationLedger; it cannot be drawn through UptoSettlement without a
new valid settle authorization, but clients SHOULD still use
autoRevoke = true to minimize residual on-chain authority.
This also removes the need for the contract to track any state of its own.
Replay protection doesn't come from a stored nonce or request record
it comes entirely from Soroban's own protocol-level behavior: every signed
authorization entry carries its own nonce, assigned at signing time, and
is consumed on first successful use. UptoSettlement doesn't need to
duplicate that bookkeeping; it just relies on the platform already doing
it.
One deliberate scope limitation follows from this: because Stellar permits
exactly one invokeHostFunction operation per transaction, each signed
authorization can only ever be consumed by exactly one settle call.
Metering many small draws against a single ceiling across multiple
transactions is out of scope for upto and is reserved for the separate
batch-settlement scheme. Each upto authorization is a single-shot,
single-transaction settlement.
PaymentRequirements for upto
{
"scheme": "upto",
"network": "stellar:testnet",
"amount": "10000000",
"asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
"payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO",
"maxTimeoutSeconds": 60,
"extra": {
"areFeesSponsored": true,
"settlementContract": "CABCDEF...UPTOSETTLEMENT"
}
}amount is phase-dependent: it's the maximum authorized at
verification time, and the actual charge at settlement time. Both phases
use the same PaymentRequirements shape; only the value of amount
differs between them. See § Phase-Dependent amount Semantics for the
full rationale.
extra field definitions:
areFeesSponsored: whether the facilitator covers the network fee for settlement. Currently alwaystruethe client never needs an XLM balance to complete payment.settlementContract: the canonicalUptoSettlementcontract address for this network one deployment per network, known in advance by the facilitator (hardcoded or config-driven), not something the facilitator looks up from this field. It's included here so the facilitator can validate the resource server's requirements against the address it already trusts (§ Facilitator Verification Rules, rule 2), catching a misconfigured or malicious resource server pointing settlement at the wrong contract. If it doesn't match, the facilitator MUST reject before verification proceeds any further.
Phase-Dependent amount Semantics in PaymentRequirements
The /verify and /settle calls to the facilitator share the same
PaymentPayload and PaymentRequirements types there is no separate
settlement-specific message type. In upto, the amount field of
PaymentRequirements carries different meaning depending on which call
it's part of:
- At verification time,
amountrepresents the maximum the client has authorized. - At settlement time,
amountrepresents the actual amount to settle, which MUST be<=the previously authorized maximum.
The resource server communicates the final charge to the facilitator purely
by setting amount to the real, metered value in the settlement-time
PaymentRequirements it sends to /settle determined from actual
resource consumption (tokens generated, bytes transferred, compute used),
with no additional fields or separate settlement type needed to convey it.
payload.accepted.amount does not change between phases. It remains the
original maximum selected and signed by the client, and is therefore the
facilitator's settle-time source of truth for the ceiling. Only the separate
settlement-time requirements.amount supplied by the resource server changes
to the actual charge.
Rationale: reusing PaymentRequirements unchanged for both phases keeps
the protocol simple and avoids introducing a settlement-specific message
shape. amount naturally maps to "how much" in both contexts how much is
authorized at verification time, how much to charge at settlement time so
the same field carries the right meaning in each phase without needing to be
renamed or duplicated.
Protocol Flow
- Client makes a request to a Resource Server.
- Resource Server responds with a
402 Payment Requiredstatus and aPaymentRequiredheader whoseaccepts[]entry hasscheme: "upto"andamountset to the maximum the client may be charged. - Client computes
expirationLedgeroff-chain:currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds)(fallbackestimatedLedgerSeconds = 5) see § Time Semantics. This value, along withdeadline, becomes part of what the client signs; the contract never computes it. - Client builds a candidate invocation using a funded simulation source
G-account that is different from
from. This is mandatory for a C-account payer (a C-account cannot be a transaction source) and mandatory for this sponsored flow with a G-account payer as well. The source is used only to make the recording-mode simulation produce an address-credential auth entry for the payer; it is not included in the signed invocation and need not be the eventual facilitator. Usingfromas the transaction or operation source would produce implicit source-account authorization, which cannot be transplanted into the facilitator-sponsored transaction. The candidate invokesUptoSettlement.settle(from, payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke, amount)amounthere is a placeholder (e.g.0) used only to shape the simulation locally; it plays no role in what gets signed. Every other argument, includingexpirationLedgerfrom step 3, is real and final. - Client simulates this candidate call to identify the required
authorization entries: the root invocation over
(payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke), withtoken.approve(from, UptoSettlement, maxAmount, expirationLedger)as a sub-invocation, and only ifautoRevoke = truetoken.approve(from, UptoSettlement, 0, 0)as a second sub-invocation. - Client asks the payer's auth-entry signer to sign the entry:
- for a G-account, the signer produces the protocol-defined Ed25519 account signature (collecting enough configured signer weight to satisfy the account threshold where necessary);
- for a C-account, the connected smart-wallet implementation produces the
contract-specific signature value accepted by that account's
__check_auth. The client signs only the authorization entry there is no meaningful full transaction to sign at this point, sinceamountis not known yet.
- Client attaches the signed entry to the candidate and re-simulates in
enforcing mode. For a C-account this executes
__check_auth; for a G-account it verifies the Stellar-account signature and threshold. The client MUST reject if this simulation fails or still reports missing signers. - Client encodes the signed authorization entry (base64 XDR) along
with the plaintext witness fields and sends them to the resource server
as the
PaymentPayload. - Resource Server forwards
PaymentPayloadandPaymentRequirements(withamountstill set to the authorized maximum) to the Facilitator's/verifyendpoint. - Facilitator reconstructs a candidate
settleinvocation usingamount = requirements.amount(the maximum, at this phase), attaches the client's signed authorization entries, and validates structure, auth entry shape, expiration, and the bound fields (§ Facilitator Verification Rules). - Facilitator simulates this candidate call in enforcing mode at the worst case
(
amount = maxAmount) to confirm it would succeed, and returns aVerifyResponse. - Resource Server, upon successful verification, serves the resource and determines the actual amount to charge based on consumption.
- Resource Server forwards the payload to the Facilitator's
/settleendpoint withrequirements.amountnow set to the actual charge.- NOTE:
/settleMUST perform full verification independently and MUST NOT assume prior verification.
- NOTE:
- Facilitator rebuilds the transaction with
amount = requirements.amount, its own account as source, and the client's previously signed authorization entries attached unchanged. - Facilitator re-simulates the rebuilt transaction in enforcing mode to verify it succeeds, confirms the expected transfer event, and derives the settlement fee and fresh Soroban resource data from that simulation.
- Facilitator signs the rebuilt transaction with its own key and
submits it via RPC
sendTransaction, then polls for confirmation. - Resource Server grants the Client access upon successful settlement.
sequenceDiagram
participant C as Client
participant RS as Resource Server
participant F as Facilitator
participant SC as UptoSettlement (Soroban)
C->>RS: Request resource
RS-->>C: 402 Payment Required<br/>(scheme: upto, amount = max)
Note over C: Compute expirationLedger off-chain,<br/>build candidate settle() call<br/>with placeholder amount
C->>SC: simulateTransaction(candidate)
SC-->>C: Required authorization entries
Note over C: Wallet signs auth entry<br/>(G-account or C-account)<br/>amount excluded from signature
C->>SC: enforce-simulate(signed entry)
SC-->>C: Signature / __check_auth OK
C->>RS: PaymentPayload<br/>(signed authEntries + witness fields)
RS->>F: POST /verify<br/>(payload, requirements.amount = max)
F->>SC: simulateTransaction(settle, amount = max)
SC-->>F: Simulation OK
F-->>RS: VerifyResponse (success)
Note over RS: Serve resource,<br/>meter actual consumption
RS->>F: POST /settle<br/>(payload, requirements.amount = actual)
Note over F: Rebuild transaction:<br/>source = Facilitator, amount = actual,<br/>client's authEntries attached unchanged
F->>SC: simulateTransaction(settle, amount = actual)
SC-->>F: Simulation OK<br/>(fee + resource data derived)
F->>SC: sendTransaction(signed by Facilitator)
SC-->>F: SUCCESS
F-->>RS: SettlementResponse<br/>(success, amount = actual)
RS-->>C: Grant access
PaymentPayload payload Field
The payload carries signed authorization entries, not a full signed
transaction there is no complete transaction to sign until amount is
filled in at settlement time.
{
"from": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO",
"payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO",
"asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
"maxAmount": "10000000",
"validAfter": 1755000000,
"deadline": 1755000060,
"expirationLedger": 1245678,
"salt": "9f3a45bb4d6d275472c3213d4932...",
"autoRevoke": true,
"authEntries": [
"AAAAAgAAAABriIN4poutFUmHfB6FbFJu8..."
]
}expirationLedger: REQUIRED, chosen and signed by the client not computed by the contract. The client estimates this off-chain (current ledger + padding fordeadline's remaining seconds, same formula as before, just done client-side rather than on-chain) and includes it as part of what's signed. This is a correctness requirement, not a style choice: Soroban checks a signed sub-invocation's arguments by exact match, so if the contract instead recomputed this value internally at settlement time using whatever the current ledger happens to be at that later moment it would almost never match the value baked into theapprove(maxAmount, expirationLedger)sub-invocation the client actually signed back when the authorization entry was built. Real time elapses between signing and settlement (verification, resource-serving, and settlement submission all happen in between), so the ledger sequence reliably moves in that window a contract-side recomputation would reliably produce a mismatch and fail authorization, not just occasionally.authEntries: base64-encoded XDR of the signedSorobanAuthorizationEntryobjects the root entry over(payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke), plus itsapprove(maxAmount, expirationLedger)sub-invocation and, ifautoRevoke, theapprove(0, 0)sub-invocation. All three are captured within a single rootSorobanAuthorizationEntry's invocation tree, so in practice this is a one-element array. This version requires exactly one entry; the array shape is retained for wire-format forward compatibility.from: a validG...orC...Stellar address. No separateaccountTypediscriminator is sent; the address variant already identifies the payer kind, and the credential signature itself remains opaque to x402.- The address credential's
signatureExpirationLedgerMUST equalexpirationLedger. Using one ledger value for both the signed auth entry and temporary allowance prevents the two lifetimes from drifting apart. autoRevoke: clients SHOULD set this totrue. Iffalse, any unspent allowance remains untilexpirationLedger, although the stateless settlement contract has no independently callable path that can consume it.salt: REQUIRED. A client-chosen, application-layer discriminator not a cryptographic requirement. Soroban assigns each signed authorization entry its own nonce at signing time, independent of its arguments, so two authorizations remain independently replay-safe even if every other field is identical.saltexists for a narrower, operational reason: two concurrent authorizations to the same resource server can easily end up with an identical(payTo, asset, maxAmount, validAfter, deadline, autoRevoke)tuple e.g. two requests landing on the same price tier and the same deadline rounding which would otherwise be indistinguishable to any tooling that keys off those fields (request logs, idempotency checks, correlating a payload back to the resource server's own request ID). Making it required, rather than optional, means every implementation gets this disambiguation by default instead of only when a client happens to think of it.
UptoSettlement Contract Interface
use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, IntoVal};
#[contract]
pub struct UptoSettlement;
#[contractimpl]
impl UptoSettlement {
pub fn settle(
env: Env,
from: Address,
pay_to: Address,
asset: Address,
max_amount: i128,
valid_after: u64,
deadline: u64,
expiration_ledger: u32,
salt: BytesN<32>,
auto_revoke: bool,
amount: i128,
) -> i128 {
// 1. Verify authorization every field except `amount` is bound.
// expiration_ledger is included here: it must be replayed verbatim
// into the approve() calls below, exactly as signed see note below.
from.require_auth_for_args(
(
pay_to.clone(),
asset.clone(),
max_amount,
valid_after,
deadline,
expiration_ledger,
salt.clone(),
auto_revoke,
)
.into_val(&env),
);
// 2. Verify validity window
let now = env.ledger().timestamp();
if now < valid_after {
panic!("not_yet_valid");
}
if now >= deadline {
panic!("expired");
}
// 3. Verify payment parameters
if max_amount <= 0 || amount < 0 || amount > max_amount {
panic!("invalid_amount");
}
let token = token::TokenClient::new(&env, &asset);
let this = env.current_contract_address();
// 4. Grant this contract temporary approval up to max_amount, using the
// client-supplied expiration_ledger NOT recomputed here. This must
// match, byte-for-byte, the value baked into the client's pre-signed
// approve() sub-invocation, or the authorization check fails.
token.approve(&from, &this, &max_amount, &expiration_ledger);
// 5. Execute the transfer contract is spender & invoker, so this leg
// needs no separate signature
if amount > 0 {
token.transfer_from(&this, &from, &pay_to, &amount);
}
// 6. Optional cleanup only if the client opted in and the transfer
// didn't already drain the allowance to zero on its own
if auto_revoke && amount < max_amount {
token.approve(&from, &this, &0, &0);
}
amount
}
}expiration_ledger is chosen and signed by the client it is not
computed inside the contract. An earlier draft of this spec had the
contract derive it from deadline via a helper (ledger_seq_for) at
settlement time. That's wrong, not just suboptimal: Soroban checks a signed
sub-invocation's arguments by exact match against what's submitted
on-chain. The client's signature is created against a specific
approve(max_amount, expiration_ledger) sub-invocation, with a specific
expiration_ledger value baked in at signing time (when the client
simulates the candidate call, using whatever the current ledger sequence
happens to be then). If settle() instead recomputed this value itself
at execution time using the ledger sequence at that later moment,
after verification and resource-serving have both taken place the
recomputed value would almost never equal the one in the signed entry,
since real time (and therefore ledger sequence) reliably advances in
between. The call would fail authorization essentially every time, not
occasionally. The fix is for the client to choose expiration_ledger
themselves (off-chain, using currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds) with the network estimate or the 5-second fallback),
sign it as part of the witness, and have settle() simply replay
that exact value into its approve() calls rather than deriving anything.
Facilitator Verification Rules (MUST)
A facilitator verifying an upto payload on Stellar MUST enforce all of the
following before sponsoring and signing the transaction at both /verify
and /settle, each independently. /settle MUST NOT skip these checks on
the assumption that /verify already ran.
1. Protocol Validation
x402VersionMUST be2.- Both
payload.accepted.schemeandrequirements.schemeMUST be"upto". payload.accepted.networkMUST matchrequirements.network.- The candidate and rebuilt transaction MUST contain exactly one operation,
of type
invokeHostFunction, with no operation-level source account.
2. Witness Field Consistency
payload.payToandpayload.assetMUST equal both their correspondingpayload.acceptedfields and the currentrequirementsfields exactly.payload.maxAmountMUST equalpayload.accepted.amount, which remains the original authorized maximum at both phases.- At
/verify,requirements.amountMUST equalpayload.maxAmount. - At
/settle,requirements.amountis the actual charge and MUST satisfy0 <= requirements.amount <= payload.maxAmount; it MUST NOT be substituted for the maximum when checking the signed authorization tree. payload.accepted.extra.settlementContractandrequirements.extra.settlementContractMUST both match the canonical contract address configured forrequirements.network.payload.accepted.maxTimeoutSecondsMUST equalrequirements.maxTimeoutSeconds, and bothpayload.accepted.extra.areFeesSponsoredandrequirements.extra.areFeesSponsoredMUST betrue.payload.maxAmountMUST be greater than0,validAfterMUST be strictly less thandeadline, anddeadline - validAfterMUST NOT exceedrequirements.maxTimeoutSeconds.- At both phases, the latest closed ledger timestamp MUST satisfy
validAfter <= now < deadline. At/verify,deadlineMUST also be no later thannow + requirements.maxTimeoutSeconds.
3. Authorization Entry Structure
- The authorization entry's root invocation MUST target
UptoSettlement.settleon the canonicalsettlementContract, with args exactly(payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke)amountMUST NOT appear in the signed argument tuple. - The root invocation's
subInvocationsMUST contain exactly:token.approve(from, settlementContract, maxAmount, expirationLedger), and, if and only ifpayload.autoRevoke = true,token.approve(from, settlementContract, 0, 0). No other sub-invocations are permitted. TheexpirationLedgervalue here MUST matchpayload.expirationLedgerexactly both come from the same client-signed field, and the contract itself never modifies or recomputes it. - Exactly one authorization entry is permitted. Its credential address MUST
equal
payload.from, and its nonce MUST be present. - Credential type MUST be address-based authorization, not source-account
implicit authorization. Implementations MUST accept legacy
sorobanCredentialsAddress, and MAY accept newer address credential variants (includingsorobanCredentialsAddressV2and delegated-address credentials) on networks where those variants are active. The credential address MUST be eitherAddress::Account(G-account) orAddress::Contract(C-account) and MUST equalpayload.from. - The facilitator MUST treat the credential's
signaturefield as opaque. For a G-account it has the protocol-defined account-signature shape; for a C-account itsScValtype and contents are defined entirely by that contract's__check_auth. The facilitator MUST NOT require an Ed25519 shape for a C-account or attempt to reproduce wallet-specific verification off-chain. - The credential's
signatureExpirationLedgerMUST equalpayload.expirationLedger. That value MUST be at least the current ledger and MUST NOT exceedcurrentLedger + ceil(requirements.maxTimeoutSeconds / estimatedLedgerSeconds). Implementations SHOULD use the current network estimate forestimatedLedgerSecondswhen available and MUST fall back to5seconds when no estimate is available. The same rule applies independently at/verifyand/settle. Since the client chooses this value themselves (§UptoSettlementContract Interface), the facilitator MUST independently bound it rather than trusting it an excessively distantexpirationLedgerwould leave the granted allowance live far longer than the authorization's actualdeadlineimplies, which is a real (if minor) hygiene concern even though the contract's owndeadlinecheck still gatessettle()itself.
4. Maximum Amount Enforcement (settlement time)
- At settle time,
requirements.amount(the actual charge) MUST be<= payload.maxAmount. This is enforced independently by the contract itself (settlepanics on violation) the facilitator MUST still check it before submitting, to avoid paying a fee for a transaction it can predict will fail. requirements.amountMAY be0.- Negative settlement amounts MUST be rejected.
5. 🚨 Facilitator Safety
- The transaction source account the facilitator builds MUST be the facilitator's own address, never the client's.
- The facilitator MUST NOT be the
fromaddress. - The facilitator address does not appear anywhere in the signed authorization this scheme is deliberately facilitator-agnostic, so any party holding the signed entries may submit settlement. Deployments that need settlement restricted to one specific facilitator MUST enforce that off-chain (e.g. the resource server only forwards payloads to a trusted facilitator's endpoint).
- For a positive amount, the simulation MUST emit exactly the expected token
transfer (payer decrease and recipient increase) and no other balance
changes. For amount
0, it MUST emit no transfer and no balance change. Expectedapproveevents MAY also be present.
6. Simulation
- The facilitator MUST re-simulate against current ledger state at both
/verify(worst-caseamount = maxAmount) and/settle(amount = requirements.amount). - Both simulations MUST run with the signed auth entry attached, in enforcing
mode. A C-account's
__check_authMUST execute successfully; a G-account's signature and configured threshold MUST validate. Structural inspection alone is never sufficient proof of authorization. - The simulation MUST succeed without errors and MUST confirm the exact
balance change specified by the phase-appropriate
amount. - A C-account's
__check_authMAY emit diagnostic/contract events or update that account's own policy state (for example, a spending-limit counter). Such account-auth effects do not invalidate an otherwise correct payment, but all token balance changes must still be limited to the expected payer debit and recipient credit, and the facilitator's fee ceiling still applies.
Transaction Fees
- The facilitator MUST derive the settlement fee from a fresh simulation at
settle time:
simulationResourceFee + inclusionBuffer(buffer>= 100stroops). - The facilitator MUST refresh Soroban resource data (footprint,
resourceFeecap) from that same simulation. - Since the client never builds a full transaction, there is no client-set fee bid to override the facilitator determines the entire fee itself from simulation.
- A
maxTransactionFeeStroopssafety ceiling applies (default 50,000 stroops, operator-overridable). Exceeding it MUST cause the facilitator to reject withinvalid_upto_stellar_payload_fee_exceeds_maximum.
Settlement Logic
Phase 1: Transaction Reconstruction
- Parse the client's signed authorization entries.
- Build a fresh
invokeHostFunctionoperation callingUptoSettlement.settle(from, payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke, amount), withamount = requirements.amount(the phase-appropriate value max at verify, actual at settle) and the client's signed authorization entries attached. - Re-simulate and derive the settlement fee and fresh Soroban resource data from the result.
- Assemble the transaction with:
- Source Account: Facilitator's Stellar address (spends its own sequence number, pays fees).
- Operation: The single
invokeHostFunctioncall above. - Auth Entries: The client's signed entries, unmodified.
- Fee / Soroban Data: As derived in step 3.
Phase 2: Transaction Submission
- Sign with the facilitator's key.
- Submit via RPC
sendTransaction. - Confirm
PENDING, then poll forSUCCESS/FAILED.
Phase 3: SettlementResponse
{
"success": true,
"transaction": "a1b2c3d4e5f6...",
"network": "stellar:testnet",
"payer": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO",
"amount": "3400000"
}success: whether settlement succeeded.errorReason: omitted on success; present and machine-readable on failure.transaction: the confirmed settlement transaction hash.network: CAIP-2 network identifier.payer: thefromaddress.amount: actual base units charged, echoing the settlement-phaserequirements.amount. MAY be"0".
Time Semantics: Timestamp vs. Ledger Sequence
Stellar has two independent expiration systems in play here, and this scheme touches both:
validAfter/deadline(wire fields, Unix seconds) are the contract-enforced authority, checked viaenv.ledger().timestamp().- The signed authorization entry's own expiration and the
expirationLedgerargument passed to the positiveapprovecall are the same ledger sequence number there is no exact protocol-guaranteed seconds-per-ledger conversion (~5s average, not a guarantee).
Implementations MUST treat deadline as authoritative. The client
computes expirationLedger off-chain using
currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds) and uses
the current network estimate for estimatedLedgerSeconds when available
(fallback estimatedLedgerSeconds = 5). The client uses that same value as
the auth entry's
signatureExpirationLedger. The contract does not compute or
adjust this value at settlement time (§ UptoSettlement Contract
Interface explains why: a contract-side recomputation would almost never
match what was actually signed, since real time elapses between signing
and settlement). Underestimating at signing time risks the allowance or
the auth entry itself expiring before deadline is reached which would
silently block a settlement that the contract's own deadline check would
otherwise have accepted.
Error Codes
invalid_upto_stellar_settlement_not_yet_valid:env.ledger().timestamp() < validAftersettlement attempted before the authorization's validity window opens.invalid_upto_stellar_settlement_expired:env.ledger().timestamp() >= deadlinesettlement attempted after the authorization's validity window has closed. Note this can also surface indirectly as a simulation failure if the auth entry's own ledger-sequence expiration (§ Time Semantics) lapses first implementations SHOULD distinguish the two where possible, since one indicates the wire-leveldeadlinewas reached and the other indicates the padding computed forexpirationLedgerwas insufficient.invalid_upto_stellar_settlement_exceeds_amount: attempted settlement amount exceedsmaxAmount.invalid_upto_stellar_settlement_negative_amount: attempted settlement amount is negative. An amount of zero is valid and consumes the authorization without transferring tokens.invalid_upto_stellar_payload_invalid_max_amount:maxAmountis not a positive integer.invalid_upto_stellar_account_authentication: enforcing-mode simulation rejected the payer authorization. For a G-account this includes invalid or insufficient signatures; for a C-account it includes a malformed proof or rejection by__check_auth.invalid_upto_stellar_payload_fee_exceeds_maximum: settlement fee derived from simulation exceedsmaxTransactionFeeStroops.UPTO_ALLOWANCE_REQUIRED(with412): the reconstructed transaction fails simulation at verify time (e.g. insufficient balance, missing trustline for a classic asset wrapped by a Stellar Asset Contract).
Security Considerations
- No overcharge:
amount > maxAmountis rejected both by the contract's own check and, redundantly, by the SEP-41 allowance the contract granted itself moments earlier in the same transaction. - No redirection:
payTois part of the signed root invocation; nothing insettle()can alter it after the fact. - Allowance lifetime: because
approveandtransfer_fromhappen in the same atomic transaction, there is no race window between them. WhenautoRevoke = true, any unused remainder is cleared before commit. When it isfalse, the remainder survives untilexpirationLedger; the contract exposes no call that can spend it without a fresh client authorization, buttrueis still the recommended hygiene default. - Facilitator-agnostic by design: no facilitator identity is bound into the signature; any holder of the signed authorization entries can submit settlement. This trades a security property (binding settlement to one named party) for simplicity and statelessness deployments that need the former must enforce it off-chain (trusted-facilitator allowlisting at the resource-server layer).
maxAmountin the witness is informational relative to the true ceiling: the approved allowance, not the signedmaxAmountvalue alone, is what ultimately gatestransfer_from. In practice they're equal becausesettle()always approves exactlymaxAmountitself but implementers should understand that the enforcement mechanism is the allowance the contract grants, not merely a stored/compared number.- Stateless replay protection: replay prevention comes entirely from
Soroban's protocol-level, per-address authorization-entry nonce
consumption, not from any
UptoSettlement-tracked record, since none exists. - Server/metering trust: the client trusts the resource server/facilitator to meter honestly up to the authorized ceiling. Nothing in this scheme removes that assumption it governs how much the client is willing to authorize, not whether the metering itself is truthful.
- Reserved for
batch-settlement: multi-settlement/streaming against one authorization is not supported byupto. Stellar permits exactly oneinvokeHostFunctionoperation per transaction, so eachuptoauthorization can only be consumed by onesettlecall. Aggregating many small draws for later settlement belongs to the separatebatch-settlementscheme. - C-account policy compatibility: a smart wallet may enforce policies
over the authorization context. The context binds
maxAmountand includesapprove(maxAmount)but deliberately excludes the finalamount, so a spending-limit policy may conservatively account for the authorized maximum rather than the later actual charge. Clients MUST surface wallet rejection instead of weakening or rewriting the signed tree.
Appendix
Authorization Pattern
The client signs authorization entries only, never a full transaction.
This is required, not optional, for this scheme: a full transaction would
need amount fixed at build time, which defeats the entire point of
upto. The client:
- Spends no sequence number of its own.
- Requires no XLM balance (fees are fully sponsored by the facilitator).
- Signs a bounded, well-defined argument tuple rather than an entire transaction envelope, which keeps the signed payload small and its scope easy to audit.
This signing flow supports both account kinds through one auth-entry signer boundary:
- A G-account adapter may use the Stellar SDK's
authorizeEntry/basicNodeSignerpath. The resulting signature must satisfy the account's configured threshold; a multi-signer wallet is responsible for collecting sufficient signatures before returning the final entry. - A C-account adapter delegates to the smart wallet. It returns a complete
signed authorization entry containing the contract-specific signature
ScValexpected by__check_auth. The x402 client and facilitator do not interpret that proof.
In either case, the adapter MUST preserve the credential variant, credential address, nonce, root invocation, and sub-invocation tree it received. The only permitted mutations are:
- setting
signatureExpirationLedgertopayload.expirationLedger; and - populating the credential signature/proof fields.
This exception for signatureExpirationLedger is required because entries
returned by recording-mode simulation have their final expiration populated
as part of signing. Implementations MUST compare the returned entry against
the unsigned entry and reject every other mutation. If delegated credentials
are used, the delegated wrapper and signer tree MUST be constructed before
the adapter receives the entry; signing MUST NOT introduce or rewrite that
tree. An enforcing-mode simulation then provides the authoritative
verification.
Implementer References
Implementers should read the following Stellar documentation:
- Signing Soroban contract invocations — Covers auth-entry signing, G-account versus C-account authorization, Recording versus Enforcing simulation, and sponsored transactions.
- Authorization
— Covers
require_auth,require_auth_for_args, authorization entries, replay protection, and the underlying authorization model. - Stellar transaction and authorization-entry structure
— Covers
SorobanAuthorizationEntry, credentials, authorized invocation trees, and sub-invocations. - Complex Account example
— Demonstrates
__check_authand how a C-account can apply authorization policies to the invocation context. - Transaction Simulation — Covers Recording and Enforcing simulation and how authorization entries are generated and validated.
Example Authorization Entry Tree
SorobanAuthorizationEntry
├── credentials: Address(from, nonce, signatureExpirationLedger, signature)
│ signature: G-account protocol signatures OR C-account-defined ScVal
└── rootInvocation: ContractFn
contract: UptoSettlement
function: "settle"
args: [payTo, asset, maxAmount, validAfter, deadline, expirationLedger, salt, autoRevoke]
subInvocations:
├── ContractFn
│ contract: <asset token address>
│ function: "approve"
│ args: [from, spender=UptoSettlement, amount=maxAmount, expirationLedger]
│
└── ContractFn # present only if autoRevoke = true
contract: <asset token address>
function: "approve"
args: [from, spender=UptoSettlement, amount=0, expiration_ledger=0]Note that from is carried by the address credential, not repeated in the
custom root argument tuple. The credential's signatureExpirationLedger,
the root expirationLedger, and the first sub-invocation's
expirationLedger are the same client-chosen value, fixed at signing time.
The contract replays it, never derives it. The settlement amount never
appears anywhere in this tree it is supplied only at submission time, by
whichever party ultimately calls settle.