Skip to content

facilitator: implement GET /supported - #143

Merged
Miracle656 merged 4 commits into
Miracle656:mainfrom
Elizabethxxx:feat/facilitator-supported-endpoint-124
Sep 1, 2026
Merged

facilitator: implement GET /supported#143
Miracle656 merged 4 commits into
Miracle656:mainfrom
Elizabethxxx:feat/facilitator-supported-endpoint-124

Conversation

@Elizabethxxx

@Elizabethxxx Elizabethxxx commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Lens is currently only an x402 resource server (a seller) — src/middleware/x402.ts delegates verify and settle to https://facilitator.stellar.org. This PR implements the first piece of the other side of that interface: GET /supported, the capability-discovery route every x402 client hits first to decide whether a facilitator can serve them.

This is metadata only — it moves no money and holds no keys.

What changed

  • Added src/routes/facilitator.ts registering GET /supported.
  • The response is typed as SupportedResponse from @x402/core (kinds, extensions, signers) rather than hand-rolled — src/routes/facilitator.ts imports the type directly and the branch type-checks clean against it.
  • Both networks are advertised: kinds contains one entry each for stellar:pubnet and stellar:testnet, matching the CAIP-2 ids already used in src/middleware/x402.ts's NETWORK resolution (driven by STELLAR_NETWORK / dual-network config, not a second source of truth).
  • Each kind's extra reports areFeesSponsored, mirroring what ExactStellarScheme.getExtra() from @x402/stellar returns, configurable via FACILITATOR_FEES_SPONSORED (default true).
  • signers is populated from an optional FACILITATOR_SIGNER_ADDRESSES env var (comma-separated), or an empty object when unset. I deliberately did not instantiate @x402/stellar's facilitator-side ExactStellarScheme here — its constructor requires real signing keys, and capability discovery must not require live keys to answer. Once verify/settle land in a follow-up, this route should read signers from that same registered scheme instance instead of a separate env var.
  • The route is registered with config: { public: true } (bypasses API-key auth, same convention as /status and /metrics) and lives outside the GATED_ROUTES prefix list in middleware/x402.ts, so it is never x402-gated — a facilitator cannot charge for its own capability discovery.
  • Documented the new env vars in .env.example.

Comparison against facilitator.stellar.org/supported

I could not reach facilitator.stellar.org from my sandbox (outbound DNS is blocked in this environment), so I was not able to diff a live response byte-for-byte. Instead I matched the shape by reading the shipped types: SupportedResponse and SupportedKind in node_modules/@x402/core/dist/cjs/mechanisms-*.d.ts, and ExactStellarScheme.getExtra()/getSigners() in node_modules/@x402/stellar/dist/cjs/exact/facilitator/index.d.ts, which is what facilitator.stellar.org itself is built on. The response this route returns is structurally identical to what x402Facilitator.getSupported() would produce for a facilitator that has ExactStellarScheme registered on both stellar:pubnet and stellar:testnet. I'd appreciate a maintainer diff against the live endpoint if one is available, and I'm happy to adjust if anything differs (e.g. exact x402Version value, key ordering, or additional extra fields the live facilitator includes).

Tests

src/tests/facilitator.test.ts covers:

  • 200 with no payment header (never x402-gated)
  • response shape (kinds/extensions/signers present, correct types)
  • both networks present with scheme "exact"
  • areFeesSponsored present and toggleable via env
  • signers empty by default, populated correctly when FACILITATOR_SIGNER_ADDRESSES is set
  • no auth required

Test plan

  • npx tsc --noEmit passes
  • npx vitest run src/tests/facilitator.test.ts src/tests/middleware/x402.test.ts passes (19/19)
  • Maintainer diff against a live facilitator.stellar.org/supported response

closes #124

Lens is currently only an x402 resource server (a seller). This adds
the first piece of the facilitator side of the interface: GET /supported,
the metadata route every x402 client hits first to decide whether Lens
can serve them.

The response matches SupportedResponse from @x402/core (kinds,
extensions, signers) rather than a hand-rolled shape. It advertises the
exact scheme for both stellar:pubnet and stellar:testnet, driven by the
existing dual-network convention used in middleware/x402.ts, and reports
areFeesSponsored in each kind's extra to mirror what
ExactStellarScheme.getExtra() reports from @x402/stellar.

Signer addresses are optional and read from FACILITATOR_SIGNER_ADDRESSES
since this route is pure capability discovery and must not require live
signing keys to answer. The route is registered outside the x402
gating plugin's matched prefixes and marked config.public, so it is
neither payment-gated nor API-key gated — a facilitator cannot charge
for its own capability discovery.

closes Miracle656#124
@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@Elizabethxxx 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.

The reasoning in the doc comments here is the best of any PR in this batch — particularly the point that capability discovery must answer without live signing keys, and must not itself be gated behind payment. A client has to be able to call /supported before it has any payment method configured. That's exactly right and easy to get wrong.

Two things.

1. Direct collision with #142

@Anambraboi-1's #142 also implements GET /supported, in src/api/facilitator.ts, and both files export a function named registerFacilitatorRoutes. Same route path, same exported symbol, two different files, both registered from src/index.ts. Whichever merges second either fails to compile or silently shadows the other depending on import order — this won't resolve itself in a rebase.

Not your fault; two people picked up overlapping issues. But it needs deciding before either lands, so please sync with @Anambraboi-1.

2. This advertises networks that may not be configured — and that's the substantive difference

const kinds: SupportedKind[] = (['mainnet', 'testnet'] as const).map(network => ({
  x402Version: 2, scheme: 'exact', network: STELLAR_NETWORK_IDS[network], extra,
}))

Both networks are advertised unconditionally. #142 registers a scheme only when that network has a FACILITATOR_SECRET_KEY, and derives /supported from what is actually registered.

That difference matters: with no mainnet key configured, this route tells a client stellar:pubnet is supported, the client selects mainnet, and /verify then fails. The client did nothing wrong — we advertised a capability we don't have. For an RFP judged on spec compliance, a /supported that overstates is worse than one that returns fewer kinds.

Suggested resolution — take the best half of each:

  • Keep #142's derivation from registered schemes, so the answer is always truthful.
  • Keep your framing that the route must answer without live keys and must stay ungated — and your tests, which #142 is thinner on.

Concretely, that probably means this PR narrows to its tests plus the doc comments, applied against #142's implementation. Which is a slightly unsatisfying outcome for the work you did, so I want to be clear the analysis here is good — it's the duplication that forces a choice, not the quality.

Minor

FACILITATOR_SIGNER_ADDRESSES as a separate env var means the advertised signers can drift from the keys actually loaded. If it derives from the registered schemes instead, the two can't disagree.

Same @ts-ignore note as #142: prefer @ts-expect-error so it removes itself once the types resolve, and it's worth checking whether moduleResolution in tsconfig.json is the real fix — your comment says the @x402 packages ship ESM-only types, which is usually a resolution-mode problem rather than a genuinely missing type.

@Miracle656

Copy link
Copy Markdown
Owner

Following up on my review — I've made the call on the /supported collision so this isn't left waiting on a conversation between two PRs.

Decision: /supported lands in #142, not here. The deciding factor is the one substantive difference between the two implementations: facilitator.getSupported() derives its answer from the schemes actually registered, so it cannot advertise stellar:pubnet when no mainnet key is configured. This version lists both networks unconditionally, which tells a client we support a network we'd then fail to verify on.

That's not a knock on the work. Your reasoning is the better-argued of the two — particularly that discovery must answer without live signing keys and must not itself be gated behind payment. A client has to call /supported before it has any payment method at all, and that's easy to get wrong. #142 has the right derivation but none of that written down.

What I'd like this PR to become: your tests and doc comments, applied against #142's implementation. Concretely:

That leaves a smaller diff than you wrote, which I know is an unsatisfying outcome. To be clear about the reason: it's duplication forcing a choice, not a quality judgement. Two people picked up overlapping issues and that's on how they were scoped, not on you.

If you'd rather not do the rework, say so and I'll port the tests and comments across myself with attribution to you — either is fine, I'd just rather ask than assume.

One note if you do rework: FACILITATOR_SIGNER_ADDRESSES as a separate env var lets the advertised signers drift from the keys actually loaded. Deriving them from the registered schemes means the two can't disagree — worth carrying over as a suggestion to #142.

Elizabethxxx and others added 3 commits September 1, 2026 16:31
…orted-endpoint-124

# Conflicts:
#	src/routes/facilitator.ts
Miracle656#142 landed GET /supported in src/api/facilitator.ts, derived from the
schemes actually registered. This PR implemented the same route in
src/routes/facilitator.ts from a hard-coded network list, and both files
exported registerFacilitatorRoutes. Miracle656#149 has since taken that filename for
POST /settle, so the collision was threefold.

Kept from this PR the half that was better than what merged: the reasoning
that capability discovery must answer without live signing keys and must be
gated by neither x402 nor API-key auth, now a doc comment on the shipped
route; and the tests, which Miracle656#142 was thin on.

The 'advertises both networks' assertions are replaced by their inverse:
/supported must report ONLY registered schemes. Advertising stellar:pubnet
on a deployment with no mainnet key makes a client select mainnet, call
/verify and fail through no fault of its own. Under-reporting is recoverable;
overstating is not.

Dropped FACILITATOR_SIGNER_ADDRESSES / FACILITATOR_FEES_SPONSORED from
.env.example — nothing reads them now, and documenting unreachable variables
is worse than documenting none.

tsc clean; full suite 377 passed / 1 skipped.

@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 did the port myself (b2d491e), since the wave has closed and the half of this PR that was best deserves to ship.

Your framing was right and it is now the comment on the route that shipped: capability discovery must answer without live signing keys, and must be gated by neither x402 nor API-key auth. A facilitator cannot charge for its own discovery endpoint, and a client has to be able to call it before it has any payment method configured. That is easy to get wrong — the natural instinct is to put every facilitator route behind the same gate — and you got it right and said why.

What changed is the source of the answer, and it is the substantive point from my earlier review. Your version built the list from a hard-coded ; the version that merged derives it from the schemes actually registered. The difference only shows up in production: on a deployment with no mainnet key, a hard-coded list tells a client is supported, the client selects mainnet, calls , and fails — having done nothing wrong. We advertised a capability we did not have. A that under-reports is recoverable; one that overstates sends clients down a path that cannot work.

So I inverted your two network assertions into their opposite, which I think is the more valuable test either way:

  • — asserts is absent when only testnet is registered.
  • — honest emptiness over a plausible lie.

And I kept the rest of yours as-is, because they test things #142 did not: that the route answers with no header and returns neither 401 nor 402, that the body matches the shape, and that every kind carries .

Two other things I had to resolve: had since been taken by #149 for , so the filename collision was threefold rather than twofold; and I dropped / from , because nothing reads them now and documenting an unreachable variable is worse than documenting none — someone sets it, sees no effect, and has no way to find out why.

Verified: clean, full suite 377 passed / 1 skipped.

Three people picked up overlapping facilitator issues in this wave (#142, #143, #149) and all three did good work. That is a scoping failure on my side, not a coordination failure on yours.

@Miracle656
Miracle656 merged commit b877fb9 into Miracle656:main Sep 1, 2026
2 checks passed
@Miracle656

Copy link
Copy Markdown
Owner

(Reposting — my review above had its code spans stripped by a shell quoting mistake on my end. Full text below.)

What changed is the source of the answer, and it is the substantive point from my earlier review. Your version built the list from a hard-coded ['mainnet', 'testnet']; the version that merged derives it from the schemes actually registered.

The difference only shows up in production. On a deployment with no mainnet key, a hard-coded list tells a client stellar:pubnet is supported, the client selects mainnet, calls /verify, and fails — having done nothing wrong. We advertised a capability we did not have. A /supported that under-reports is recoverable; one that overstates sends clients down a path that cannot work.

So I inverted your two network assertions, which I think makes them the more valuable tests either way:

  • advertises only what is registered, never a hard-coded network list — asserts stellar:pubnet is absent when only testnet is registered.
  • reports no kinds at all when nothing is registered — honest emptiness over a plausible lie.

I kept the rest of yours as-is, because they test things #142 did not: that the route answers with no Authorization header and returns neither 401 nor 402, that the body matches the SupportedResponse shape, and that every kind carries areFeesSponsored.

Two other things I resolved during the port:

  • src/routes/facilitator.ts had since been taken by feat(x402): POST /settle — design note and implementation (closes #126) #149 for POST /settle, so the filename collision was threefold rather than twofold.
  • I dropped FACILITATOR_SIGNER_ADDRESSES and FACILITATOR_FEES_SPONSORED from .env.example. Nothing reads them now, and documenting an unreachable variable is worse than documenting none — someone sets it, sees no effect, and has no way to find out why.

Verified: tsc --noEmit clean, full suite 377 passed / 1 skipped.

Three people picked up overlapping facilitator issues in this wave (#142, #143, #149) and all three did good work. That is a scoping failure on my side, not a coordination failure on yours.

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 facilitator: implement GET /supported

2 participants