Repository navigation
Design Discussion: exact and upto for Stellar #72
Description
Activity
Solid work, answering your three questions:
-
Yes, the shape is right. We analyzed the same design space for a
scheme_upto_stellar.mddraft and arrived at the same design independently signed ceiling/recipient/expiry/nonce, unsigned actual, with the contract enforcing both the cap and single-use semantics. -
We would make
validAfterreal rather than a verify-time policy. Bindvalid_after_ledgeras a signed argument and check it on-chain insettle(). That costs one comparison and satisfies the spec’s time-bound MUST without introducing a Stellar-specific carve-out If there is a reason to keep it off-chain, we would be interested to hear it. -
One question: you addressed allowance hygiene in Support 'upto' scheme #71 — zeroing is not worth a second signature, agreed but there is a different shape that avoids the question entirely
settle()atomically pulls the max into the contract, pays the actual, and refunds the rest in the same invocation, no allowance ever exists, so there is nothing to expire or zero. Did you consider this approach and reject it for a reason we are missing?
On scope, our focus is the discovery layer the index, search, and MCP server that let agents actually find and pay for Stellar services, that work needs a solid
uptospec underneath it, so rather than write a second one, let’s align on a single spec. We are open to coordinating whenever suits you.-
@bomanaps and I have been working through a similar Stellar upto design and have opened a PR with the proposed Stellar binding:
We’ve also implemented and tested the proposed flow on Stellar testnet, with the test results included in the PR:
x402-foundation/x402#3134 (comment)The approach uses a minimal, stateless settlement contract. The client signs the ceiling and payment constraints while the actual settlement amount remains variable and is enforced on-chain as amount <= maxAmount.
validAfter is enforced on-chain, replay protection comes from Soroban authorization entries, and approve -> transfer_from-> optional revoke executes atomically in a single facilitator-sponsored transaction.
Coming at this from the discovery side rather than the settlement side, so mostly additive to where the thread already is.
I build the facilitator-side Bazaar for Stellar: the catalog, the ranking, and the MCP surface that let an agent find a service it has never seen and pay for it. It is live on testnet (stellarsight.xyz/discovery/health), it catalogs from settled payments, and it deliberately has no
uptoimplementation, because reading #71, this thread, and x402-foundation/x402#3134 it is clear the semantics are converging without me adding a fourth contract to the pile. I would rather conform to what lands here than write a competing spec, which I think is also @bomanaps's point above.What I can contribute is the requirements the discovery layer puts on the spec. These are invisible from the settlement side, and none of them are addressed yet as far as I can tell.
1. What does a listing advertise as the price of an
uptoresource?PaymentRequirements.amountin v2 is a single value. Forexactit is the price. Foruptothe only value the seller can honestly publish before the call is the ceiling, which is not the price and is usually much larger than it. A catalog that puts a ceiling in the same field an agent reads as "cost" makes every metered service look expensive next to a fixed-price one, and the comparison is not just noisy, it is systematically biased against exactly the servicesuptoexists to enable.The spec does not have to solve pricing, but it does need to say whether
amounton anuptorequirement is the ceiling, and ideally give the resource a way to state a typical or unit price alongside it. Without that, every catalog invents its own convention and agent-side price comparison stops being portable, which is the thing discovery is for.2. Budget filters silently exclude the cheap case.
The natural agent behaviour is "find me something under X". If a listing only carries the ceiling, a filter drops every
uptoservice whose ceiling exceeds the budget even when the actual settled amount would land far below it. Today that is a client-side heuristic, and everybody's heuristic will differ. AunitPriceortypicalAmounthint, even non-normative, makes the filter correct instead of conservative.3. Usage signals stop being comparable once amounts vary.
Catalogs rank partly on observed settlements. With
exact, counting settlements is a reasonable proxy for real usage. Withupto, a 0.001 settle and a 5.0 settle count the same, so a metered endpoint called constantly for tiny amounts outranks a substantial one, and gaming it gets cheaper the smaller the settlements are. If the settle response is going to carry the actual amount in a stable place, saying so normatively lets catalogs weight by value rather than by count, which is a meaningfully harder thing to fake.
On the two open questions in the thread, from where I sit:
validAfteron-chain (@bomanaps, @Iam0TI): agreed, and discovery gives an extra reason. A listing is a cached claim about a resource that a client may act on much later. The more of the payment's validity window the ledger enforces rather than the facilitator, the less a stale catalog entry can be turned into a payment nobody intended.- Allowance hygiene: the atomic pull-max, pay-actual, refund-remainder shape reads better to me than leaving a residual to expire, for the same reason: the payer's worst case is bounded by the transaction they signed rather than by an expiry they have to track. But I have not implemented either, so treat that as a preference, not a finding.
Happy to open a PR against #3134 adding the discovery-side notes if that is useful, or to just leave them here if the spec is better kept narrow. Either way I will implement whatever this converges on rather than a variant of it, and I will say so publicly when I do.
- added a commit that references this issue
on Aug 15, 2026 @pedro-pelicioni
For (1) and (2), I think richer pricing metadata belongs at the discovery layer rather than in the core upto scheme.PaymentRequirements.amount can remain clearly defined as the authorization ceiling. Trying to add a universal unitPrice or typicalAmount to the payment scheme gets difficult because upto can represent very different products: per-token inference, compute time, bytes transferred, API calls, storage, or even pricing based on multiple usage dimensions. There isn’t necessarily one meaningful unit or “typical” amount.
The discovery is better positioned to expose richer, extensible pricing metadata describing the resource’s pricing model, while the payment scheme stays concerned with the security boundary: the maximum amount the payer authorizes and the actual amount eventually settled.
That also lets Bazaar evolve pricing/search/filtering semantics without requiring changes to the underlying upto settlement specification for every new type of metered product.
For (3), I agree the actual settled amount should be exposed in a stable,fix place. But I think discovery should decide how to use it. It can derive things like total volume, average/median settlement, and transaction count, then use whatever combination makes sense for ranking. That way upto just provides reliable settlement data without defining how discovery should rank resources.
On the two open questions in the thread, from where I sit:
validAfteron-chain (@bomanaps, @Iam0TI): agreed, and discovery gives an extra reason. A listing is a cached claim about a resource that a client may act on much later. The more of the payment's validity window the ledger enforces rather than the facilitator, the less a stale catalog entry can be turned into a payment nobody intended.
Agreed. But the current Stellar upto design already binds validAfter (see here and here ) into the signed require_auth_for_args tuple and enforces it inside UptoSettlement. The discovery/stale-catalog case is a good additional reason to keep that guarantee on-chain rather than relying on facilitator policy.
- Allowance hygiene: the atomic pull-max, pay-actual, refund-remainder shape reads better to me than leaving a residual to expire, for the same reason: the payer's worst case is bounded by the transaction they signed rather than by an expiry they have to track. But I have not implemented either, so treat that as a preference, not a finding.
I think prefer approve-ma x-> transfer-actual -> revoke-remainder flow instead because maxAmount is an authorization ceiling, not an amount the payer intends to spend, so I’d rather avoid temporarily pulling the full ceiling into the settlement contract just to refund the unused portion. It also avoids the extra token movement/refund path, reducing execution overhead(like gas) and keeping the contract from unnecessarily taking custody of the full ceiling. With autoRevoke == true, only the actual amount moves and any residual allowance is cleared atomically in the same transaction, so the payer’s exposure still ends with settlement. (to be fair we are considering making autoRevoke always true so we will clear any unused allowance )
Both corrections taken.
On
validAfter: you are right and I missed it. I checked the diff — it is in therequire_auth_for_argstuple and enforced withif now < valid_afterinsidesettle. My point was already handled before I made it.On allowance hygiene: your argument is better than mine, and specifically the part I had not weighed — pulling the full ceiling into the contract to refund the remainder means the contract takes custody of money the payer never intended to spend, just to hand most of it back. That is worse than the expiry problem I was trying to avoid. With
autoRevokealways true the residual is cleared in the same transaction anyway, so the exposure window I was worried about closes without the custody hop. I withdraw the preference.On pricing metadata living in discovery rather than in the scheme: agreed, and your reason is the one that convinces me. Per-token, per-second, per-byte, per-call, per-GB-month and multi-dimensional pricing do not share a unit, so a single
unitPriceinPaymentRequirementswould either be wrong for most products or so loosely typed it means nothing.amountstaying unambiguously the authorization ceiling is the right call, and it keeps the security boundary clean.That does leave the shape undefined somewhere, though, and if the answer is "the Bazaar owns it" then I should propose it rather than keep pointing at it. What I would put up as a PR against the bazaar extension, roughly:
extensions.bazaar.pricing = { model: "per-call" | "per-token" | "per-second" | "per-byte" | "tiered" | "custom", unit: { amount: "12", per: "1000 tokens" }, // optional, omitted when meaningless typical: "350", // optional, what a median call settles note: "free-form, for models that fit none of the above" }modelandnoteare always expressible,unitandtypicalare optional precisely because your list of product types shows they are not universal. A catalog that gets none of them falls back to the ceiling and says so, which is what mine does today.The part that ties this back to your (3), and the reason I care about the settled amount being in a stable place: a seller-declared
typicalis a claim, and the catalog is the only party that can check it. Once the actual settled amount is exposed consistently, a catalog holding a resource's settlement history can compare declared-typical against observed-median and flag or down-rank the gap. That makes the field self-correcting instead of another number sellers optimise. It is also not something the scheme could enforce even if it wanted to, which I think is further evidence the metadata belongs where you are putting it.Happy to open that as a separate PR against the bazaar extension so #3134 stays narrow. Say the word if you would rather it wait until #3134 lands, since the field names should probably not move while the scheme is still settling.
Coming from the settlement-catalog side, so this is additive to your three points rather than a counter to any of them.
Checking
extra.uptoProfile(the discriminator #3098 defines for tellingcontractandstatelessapart on the wire) against our own catalog implementation surfaced something adjacent to your point 1 and 3: our dedupe key foracceptsentries is${scheme}|${network}|${asset}|${payTo}, notextra-aware. Twouptoprofiles for the same resource, network, asset, andpayTohash to the same key today, so a facilitator advertising both would have one silently overwrite the other on the next payment rather than coexist. Not the pricing/ranking problem you're describing, but the same root cause: once a listing can carry more than one shape of the same scheme, whatever key a catalog uses to store it has to know that, or it loses data instead of just displaying it awkwardly. Recorded, not fixed yet: docs/DEFERRED.md.Your
pricing/typical/settled-amount proposal for the bazaar extension would actually help this too: if the catalog schema already carries enough to distinguish listings by more than scheme/network/asset/payTo, the dedupe key has an obvious place to pick up the same discriminator from.- added a commit that references this issue
on Aug 16, 2026 Checked our catalog against your case before replying, same bar you held.
Result: same root cause, and honestly a layer worse in shape. Our records key on the resource URL and hold a single requirements tuple (scheme/network/asset/payTo/max as top-level fields), so two upto profiles for the same resource do not even collide in a dedupe key: there is nothing to collide. The second upsert replaces the whole listing and reports ok: true, no trace. And the stored record carries no
extraat all, so the profile discriminator has nowhere to live even if both entries were kept. Reproduction and disposition recorded here rather than fixed ad hoc: pedro-pelicioni/stellarsight#1. Fixing the storage key before the spec fixes the discriminator would just mean guessing the key.Which is your closing point, and I think it generalizes: once a listing can carry more than one shape of the same scheme, every catalog needs the discriminator in its storage key, and if each of us invents where it lives we get three incompatible catalogs on top of two settlement profiles. That is schema territory, not implementation territory.
So I have opened the bazaar-extension pricing proposal we discussed above as a draft PR: x402-foundation/x402#3181. It carries the pricing model/unit/typical shape from this thread, and it gives the dedupe problem an anchor too: a schema-level place catalogs can key listings on beyond scheme|network|asset|payTo. Field names deliberately held as draft until scheme_upto_stellar consolidates, per @Iam0TI's earlier point that they should not move while the scheme is settling.
Nine days quiet here, so a nudge rather than new substance on the design itself.
One relevant data point since the last comment: prompted by x402-foundation/x402#3226 (the verify-vs-settle cataloging ambiguity), we moved our own facilitator to settle-only cataloging — and found pedro-pelicioni's stellarsight had already reached the same reading independently. Two Stellar-side catalogs landing on the same answer without coordinating is the same shape of convergence this thread already showed for the upto design itself; worth having on record here too.
On the actual open question: is there a decision yet on whether #3098 or #3134 is what the wire spec consolidates onto, or is that still waiting on the working group? Happy to help however's useful if it's just stalled on bandwidth rather than a real disagreement.
- added a commit that references this issue
on Aug 25, 2026 Nine days quiet here, so a nudge rather than new substance on the design itself.
One relevant data point since the last comment: prompted by x402-foundation/x402#3226 (the verify-vs-settle cataloging ambiguity), we moved our own facilitator to settle-only cataloging — and found pedro-pelicioni's stellarsight had already reached the same reading independently. Two Stellar-side catalogs landing on the same answer without coordinating is the same shape of convergence this thread already showed for the upto design itself; worth having on record here too.
On the actual open question: is there a decision yet on whether #3098 or #3134 is what the wire spec consolidates onto, or is that still waiting on the working group? Happy to help however's useful if it's just stalled on bandwidth rather than a real disagreement.
Status from our side. #3134 is ready to consolidate on now. We signed the commits, deployed the contract, and exercised it on testnet with the evidence linked in the PR, covering both G account and C account payers. The design answers every question this thread opened. It enforces validAfter on chain inside the signed tuple, it gets replay protection straight from the protocol nonce so there is no nonce TTL to size wrong, which is the exact bug class you flagged in your own comparison, and it settles in one atomic sponsored transaction. Implementations in the wild are already building against it. If the working group picks it up we will turn around review feedback immediately. On settle only cataloging, Rialto confirms that reading too, we catalog only on successful settle and every entry already carries a provenance label, which is where #3226 is heading.
Thanks for the detailed status — that answers the open question cleanly. #3134 covers exactly the gap I was tracking: on-chain
validAfterinside the signed tuple, replay protection from the protocol nonce instead of a sized TTL, and one atomic sponsored settlement covering both G and C payers. The settle-only convergence with Rialto is a third independent confirmation now, not just two.#3134 is still
REVIEW_REQUIREDand unmerged as of today — is there anything concrete I can do to help move it, or is it purely waiting on working-group bandwidth at this point? Happy to add a review comment from the Stellar-facilitator side if that's useful signal, otherwise I'll leave this thread alone until there's movement on the PR itself.Left a review comment on #3134 with the specifics — no objection from Periplo's side on converging there, the stateless-nonce approach is a real improvement over what we shipped.
Reacted by Mercy Boma Naps Nkari
Hi @marcelosalloum, thanks for creating this repo, would inspired a lot!
I'm building an x402 facilitator for Stellar for agent payments: software paying per request, no account and no API key. It supports both settlement schemes,
exact(a fixed price) andupto(authorize a ceiling, settle the actual usage), built on the Apache-2.0@x402/stellarpackage. I compose on it, I don't reimplement verify/settle.This is the settlement logic for both, at the design level. Finer implementation detail is for a proper spec writeup, and the Bazaar discovery layer is a separate workstream. Everything is on
stellar:testnet, and every transaction below is real and opens on stellar.expert.Core model: sign the auth entry, not the transaction
The buyer signs a Soroban authorization entry, which is permission for one specific contract call with specific args, not a transaction. The facilitator wraps it in a transaction it sources, sequences, and pays for.
Three consequences:
breaking a signature it can't forge;
exact
The buyer signs
transfer(from, to, amount)on the SEP-41 token for exactly what's owed.sequenceDiagram autonumber participant B as Buyer participant S as Seller participant F as Facilitator participant L as Ledger B->>S: GET /resource S-->>B: 402 · PAYMENT-REQUIRED (price, asset, payTo) Note over B: sign the AUTH ENTRY for transfer(from,to,amount) B->>S: retry · PAYMENT-SIGNATURE S->>F: POST /verify F-->>S: isValid, nothing on-ledger yet S->>F: POST /settle F->>L: re-source to itself, sponsor the fee, submit L-->>F: success, tx hash S-->>B: 200 · resource/verifydecodes, runs structural checks, and simulates, with nothing on-ledger./settlere-sources to a facilitator signer, sponsors the fee, and submits, re-running verify first so there's no verified-then-swapped window. Two Soroban subtleties are handled here (how the payer's authorization is credentialed, and checking the transfer against the simulated result rather than the declared args); neither changes the shape above. Every rejection carries a specific, actionable reason.Settled on testnet, each link opens the on-chain payment on stellar.expert:
exact: testnet paymentOn each, the transfer's sender is the buyer and the fee is paid by the facilitator, so non-custody and sponsorship are visible on-chain.
upto
Bare SEP-41
approve/transfer_fromcan't meet two of the generic spec's MUSTs:approve(from, spender, max)lets the spender picktoat settle time, so adishonest facilitator could redirect funds. The spec requires
tobound by the client's signature.transfer_fromcalls up tomax, which ismulti-settlement. The spec forbids it, and a bare allowance has no per-authorization nonce.
So a contract sits between the signature and the transfer, as on EVM (
Permit2Proxy) and SVM (payment-channels).Design: the client signs a ceiling bound to the recipient, an expiry, and a nonce, and leaves the actual amount unsigned. The facilitator fills the actual amount in at settle without invalidating the signature, and the contract enforces
actual ≤ maxon-ledger and single-use.The settlement contract is deployed on testnet: view it on stellar.expert.
flowchart LR subgraph signed["client signs"] A["ceiling · recipient · expiry · nonce"] end subgraph unsigned["facilitator fills at settle"] X["actual amount"] end A --> C{{"upto contract"}} X --> C C -->|"enforce actual ≤ max, single-use"| T["transfer"]Two design points, since they were open questions: it's a single transaction (no separate approve on the hot path), and fee sponsorship works exactly as in
exact(the payer needs no XLM). Finer points, like handling the leftover allowance, are for the spec.One deviation: the spec's time-bound MUST wants both
validAfteranddeadline. Soroban auth entries have no native "not valid before", so I implementdeadline(expiration_ledger) and treatvalidAfteras off-chain verify-time policy. Open to input if Stellar wants a real start-time.Settled on testnet, each a partial settlement with over-cap and replay refused in the same run:
Both account types
The facilitator is address-agnostic. It validates the authorization and lets the ledger enforce the credential type. The difference is buyer-side:
__check_authruns on-ledger. I build on an audited OpenZeppelinaccount and add only a spending-budget policy, so an over-budget payment is refused on-chain by the
wallet itself. It settled both schemes from a contract account, with an over-budget attempt declined on-ledger:
exacttestnet payment,uptotestnet payment.exactfrom a contract account through the stock client needed a one-line@x402/stellarfix (it never forwarded anauthorizeEntryoverride), filed as x402-foundation/x402#3018. The facilitator needed no change.Status
Both schemes settle end to end, keypair and smart account, self-issued asset and real USDC, non-custodial and fee-sponsored, with overspend and replay refused on-ledger and a coded reason on every rejection.
/supportedadvertises both with theuptocontract address andareFeesSponsored. That's the design I'd like checked.A few things I'd genuinely value your view on:
uptoshape look right to you, or would you design it differently? The client signs a ceiling, the actual amount is left unsigned, and the contract enforcesactual ≤ maxand single-use on-ledger.deadline-only acceptable for Stellar, or would you want a realvalidAfterstart-time?The contract and the finer details aren't public yet, I'm still tidying them up for a proper spec writeup. If you're interested I'm glad to share the contract and walk through any of it privately.
What do you think, and let me know if you have any other questions.