Skip to content

x402 conformance: baseline the reference facilitator and publish the report - #153

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
DevTobis:docs/x402-conformance-baseline
Sep 1, 2026
Merged

x402 conformance: baseline the reference facilitator and publish the report#153
Miracle656 merged 1 commit into
Miracle656:mainfrom
DevTobis:docs/x402-conformance-baseline

Conversation

@DevTobis

@DevTobis DevTobis commented Aug 31, 2026

Copy link
Copy Markdown

closes #127

Summary

docs/x402-conformance.md — what the public x402.org facilitator actually does on stellar:testnet, established by running against it rather than by reading the spec. Everything in the document was executed live on 2026-08-31; nothing is inferred.

Headline: a payment settled end to end

An exact payment on stellar:testnet, verified and settled by the public facilitator with an unmodified @stellar/stellar-sdk 16.2.0 client — no patched SDK, no local shims.

Transaction 988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17
Ledger 4425809, successful: true
Recipient balance went to exactly 1.0000000

areFeesSponsored: true verified empirically, not just read off /supported: the submitted transaction's source account is the facilitator's own (GC6CSXBV…YN3K), not the payer's, and the 23,059-stroop fee was charged to it.

Divergences found

Five, ranked. The first is the one that will actually hurt an integrator:

  1. An unsupported network, scheme or version returns HTTP 500 unexpected_error, not a rejection. stellar:pubnet, upto on Stellar, and x402Version: 1 all produce a 500. These are client errors — /supported already publishes the right answer — and a stock HTTP client reads 500 as "server broke, retry", so it will hammer a facilitator that can never say yes. The x402Version: 1 case is worse: the message reads "No facilitator registered for scheme: exact and network: stellar:testnet", which is untrue as written — that pair is registered, at v2. The message names scheme and network but omits the version, which is the field that actually caused the miss.
  2. Rejections carry no invalidMessage. Every structured rejection is exactly {"isValid":false,"invalidReason":"…"} — the field is absent, not null. It appears only on the 400/500 paths.
  3. Expired and replayed payloads are indistinguishable. Both report invalid_exact_stellar_payload_simulation_failed, the same reason an insufficient balance gives. Those need opposite client responses (re-sign and retry vs. stop, already spent). The spec has an explicit expiry rule, so a specific reason is available — and note the other direction is handled correctly, with its own invalid_exact_stellar_signature_expiration_too_far.
  4. payer is echoed inconsistently — present on every rejection class except wrong_asset.
  5. /supported mixes CAIP-2 with legacy names — the two x402Version: 1 rows use base-sepolia and solana-devnet, so "parse network as CAIP-2" is wrong unless version is checked first.

On the RFP's checks, one by one

  • /supported verbatim against the stated contract — captured. The Stellar entry is {"x402Version":2,"scheme":"exact","network":"stellar:testnet","extra":{"areFeesSponsored":true}}. Matches. All 11 kinds tabulated. Stellar mainnet is not served, so nothing about mainnet can be inferred from this facilitator.
  • payload: {transaction} accepted verbatim — yes, first attempt, no adjustment.
  • Every rejection carries a non-null reason — yes for invalidReason; no for anything human-readable (divergence 2).
  • Deliberate bad input — wrong asset, wrong recipient, wrong amount, expired signatureExpirationLedger, replayed payload, plus nine malformed-payload cases. Each was built by mutating one field of the payload that actually settled, so a specific reason was genuinely available to the facilitator in every case. Full table in the doc.

Two things I want to be straight about

This is not a run of the x402 repo's own e2e suite. That suite is not runnable against an arbitrary facilitator without vendoring its harness. What I did instead is the check the RFP actually describes — a canonical client built straight from the published spec (pinned at commit c4d2de65) with the stock SDK, completing a payment end to end. Weaker than running their suite; stronger than reading the spec; and the settled hash is verifiable by anyone. The doc says so in its own scope note rather than letting the claim drift.

I have not filed the upstream issue yet. The issue asks for divergences to be filed on the x402 repo and linked here, and divergence 1 clearly deserves it. I stopped short because that is a public post to a third-party project under a maintainer's identity, which felt like your call rather than mine. Say the word and I will open it and add the link — the writeup is ready.

Separately: a live bug in this repo

facilitator.stellar.org — hard-coded as the default X402_FACILITATOR_URL in src/middleware/x402.ts and src/api/websocket.tsdoes not resolve. Google Public DNS returns Status: 3 (NXDOMAIN), with the SOA for stellar.org. in the authority section: the parent zone exists, the label does not.

Any Lens deployment that has not set X402_FACILITATOR_URL is gating paid routes behind an unreachable facilitator. This is exactly the "advertised support and reachable support are not the same thing" gap the RFP screens for, except it is ours. I have kept it out of this PR's fix scope — it wants its own issue and its own change — but it is recorded in the doc and worth acting on before it reaches a deployment.

What this gives #124#126

The doc closes with six concrete requirements for our facilitator derived from the gaps above, including matching the reference implementation's reason identifiers (wrong_recipient, wrong_asset, wrong_amount, signature_expiration_too_far, unsupported_credential_type) so a client can treat both uniformly.

It also records the one detail that costs an hour to rediscover: the transaction source must differ from the payer, or Soroban simulation returns a sorobanCredentialsSourceAccount entry and the facilitator correctly rejects it with invalid_exact_stellar_payload_unsupported_credential_type. The spec implies this but never says it.

A changeset is included per CONTRIBUTING.

Establishes what the reference facilitator actually does, so "conformant"
means something other than our own reading of the spec. Everything in the
document was executed against live testnet.

A real exact payment was verified and settled through the public
x402.org facilitator with an unmodified @stellar/stellar-sdk 16.2.0
client - tx 988dca9e7a204a59534912ff502fd81056f571e35f2cb87812bdfdc3292d9f17,
ledger 4425809. Fee sponsorship is confirmed empirically: the submitted
transaction's source is the facilitator's own account, not the payer's.

Also records /supported verbatim, a reason-per-rejection table built by
mutating one field at a time on that same valid payload, and five
divergences - chief among them that an unsupported network, scheme or
version returns HTTP 500 unexpected_error rather than a rejection, which
tells a stock client to retry a request that can never succeed.

Separately, and not a conformance finding: facilitator.stellar.org, which
this repo hard-codes as the default X402_FACILITATOR_URL in
src/middleware/x402.ts and src/api/websocket.ts, does not resolve
(NXDOMAIN).
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@DevTobis Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved and merging. I independently verified the load-bearing claims rather than taking them on trust, and all of them hold.

Against live Horizon testnet for 988dca9e…d9f17:

successful:      True
ledger:          4425809
source_account:  GC6CSXBV4C6RL3HEDTW57KXYXSSXKAWKGYDEOSATXM3XNKXSR2VRYN3K
fee_charged:     23059

The source account is the facilitator's, not the payer's GBJ72MK5… — so extra.areFeesSponsored: true is backed by an observed on-chain fact, not by an advertised flag. I also re-fetched /supported live and it matches what you recorded. That is the difference between a conformance document and an opinion, and it is why this belongs in the repo.

The scope note is what earns the document its credibility. Saying plainly that this is not a run of the x402 repo's own e2e suite, that the suite isn't runnable against an arbitrary facilitator without vendoring its harness, and that this is weaker than running their suite but stronger than reading the spec — that is the paragraph a reviewer looks for and almost never finds. A conformance report that overstates its method is worse than none, because people stop checking.

Three findings I want to make sure don't get lost:

  1. HTTP 500 unexpected_error for an unsupported network/scheme/version. This is the serious one. A 500 tells a stock client to retry, so a request that can never succeed gets retried forever. That is a client-side hang caused by a server-side status code choice.
  2. Already-expired auth collapses into the generic …simulation_failed, indistinguishable from a replay or an insufficient balance — while the too-far-in-future direction gets its own named reason. The asymmetry is worth knowing before Lens's own facilitator is measured against it.
  3. CAIP-2 holds only for x402Version: 2. The two v1 rows use legacy base-sepolia / solana-devnet. A client that reads /supported and assumes CAIP-2 throughout mis-parses those rows — an easy bug to write and a hard one to find.

Building the rejection table by mutating one field at a time off the same payload that actually settled is the right method: it proves a specific reason was available to the facilitator, so a generic one is a real finding rather than an artifact of sending garbage.

And the aside at the end is not an aside. facilitator.stellar.org returns NXDOMAIN — I confirmed it, Non-existent domain, connection code 000. Lens hard-codes it as the default X402_FACILITATOR_URL in src/middleware/x402.ts:22, src/api/websocket.ts:10 and .env.example. So any deployment that doesn't set that variable fails every payment verification, and this document is what surfaced it. I'm fixing the default to https://x402.org/facilitator — which I verified is live and serves stellar:testnet — in a follow-up commit on main, and crediting this PR.

Excellent work. Noting the effort/reward mismatch too: docs-only diffs are the least glamorous thing to submit and this one found a production bug.

@Miracle656
Miracle656 merged commit dca5ff3 into Miracle656:main Sep 1, 2026
1 check passed
Miracle656 added a commit that referenced this pull request Sep 1, 2026
X402_FACILITATOR_URL defaulted to https://facilitator.stellar.org, which
returns NXDOMAIN. Any deployment that did not set the variable explicitly
therefore failed every payment verification and settlement — the default
was not merely stale, it pointed at nothing.

Surfaced by the conformance baseline in #153, which settled a real testnet
payment through https://x402.org/facilitator (tx 988dca9e…d9f17, ledger
4425809) and recorded its /supported response. Verified again here: the
old host does not resolve, the new one is live and advertises
stellar:testnet with areFeesSponsored: true.

Credit: DevTobis (#153).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

x402 conformance: baseline the reference facilitator and publish the report

2 participants