Skip to content

feat: implement x402 facilitator routes (#125) - #142

Merged
Miracle656 merged 6 commits into
Miracle656:mainfrom
Anambraboi-1:feat/x402-facilitator
Aug 30, 2026
Merged

feat: implement x402 facilitator routes (#125)#142
Miracle656 merged 6 commits into
Miracle656:mainfrom
Anambraboi-1:feat/x402-facilitator

Conversation

@Anambraboi-1

@Anambraboi-1 Anambraboi-1 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Implements the x402 facilitator verify, settle, and supported routes using @x402/stellar. Closes #125

@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@Anambraboi-1 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.

This is the core of the x402 RFP deliverable and the structure is right: x402Facilitator with per-network ExactStellarScheme registration, both stellar:testnet and stellar:pubnet, graceful degradation when no key is configured. Good work. Four things before it lands.

1. It carries #141's deploy blocker

The first commit here is #141 (feat: Add network discriminator to all models + Redis key prefix — identical blob hashes, c36ea2f..102f9a6). So this PR inherits the problem I've just described on that one: every network column is declared with no default, and prisma db push against the populated production database fails outright, suggesting --force-reset (which drops it).

Fix it in #141, merge that first, then rebase this. Details and the verified fix are in my review over there.

2. /supported collides with #143

#143 (@Elizabethxxx) implements GET /supported as a standalone route in src/routes/facilitator.ts. This PR implements the same endpoint in src/api/facilitator.ts. Two different files, same path — whichever merges second either conflicts or silently shadows the other depending on registration order.

This needs a call rather than a rebase. My read: keep the /supported here and reduce #143 to its tests, because facilitator.getSupported() derives the answer from the schemes actually registered, whereas a standalone route has to restate the list and can drift out of sync with what the facilitator can really do. But it's worth checking with @Elizabethxxx — if #143 has coverage this lacks, that's the more valuable half to keep.

3. @ts-ignore on the two most important imports

// @ts-ignore
import { x402Facilitator } from '@x402/core/facilitator'
// @ts-ignore
import { ExactStellarScheme } from '@x402/stellar/exact/facilitator'

These suppress checking on exactly the boundary most likely to be wrong — deep subpath imports into a fast-moving package. If either export path is renamed or doesn't exist, this compiles clean and throws at startup in production.

At minimum use @ts-expect-error, which fails the build once the suppression is no longer needed, so it self-removes when the types land. Better: find out why the types don't resolve. Usually it's a missing "moduleResolution": "bundler" / "node16" for packages that ship exports maps — worth checking tsconfig.json before suppressing, since fixing that gives you real types across the whole x402 surface.

4. Errors: everything becomes a 400, and err.message goes to the caller

return reply.status(400).send({
  isValid: false,
  invalidReason: 'internal_error',
  invalidMessage: err.message || 'Verification failed'
})

Two problems in three lines:

  • A 400 says "the client sent something wrong". If verification failed because our Soroban RPC is down, that's a 5xx — our fault, and retryable. Reporting it as 400 tells the resource server the payer's payload was bad, so it rejects a legitimate payment and the user sees a failure that isn't theirs. Worth mapping RPC/network faults to 502/503 and keeping 400 for genuinely malformed input.
  • invalidReason: 'internal_error' — is that in the x402 spec's reason enum? If not, spec-compliant clients may not handle it. Since this PR is aimed at an RFP judged on spec compliance, that's worth getting exactly right, and it's the kind of detail #137's spec work should pin down.

Also, err.message is passed straight to the caller. Errors from RPC clients routinely embed endpoint URLs; our mainnet RPC endpoint is a secret (it's a paid provider key). Log the detail, return a generic message.

5. Worth a thought: /settle is public and spends real XLM

config: { public: true } skips API-key auth, and the schemes are configured with areFeesSponsored: true plus maxTransactionFeeStroops. So every successful settle spends the facilitator's fee-payer balance, and the endpoint is open to anyone.

The global limiter does bound this — 100 requests/min per IP for unauthenticated callers — so it's not unbounded. But 100 sponsored transactions per minute per IP is still a meaningful drain, and IPs are cheap.

I don't think this blocks the PR: a facilitator's /settle is meant to be callable by resource servers, and the x402 model assumes it. But before this points at a funded mainnet fee payer we want either a much tighter per-route limit on /settle, an allow-list of resource servers, or a hard daily spend cap. Could be a follow-up issue rather than part of this PR — your call, but let's not discover it after the fee payer is empty.

Nice

Registering per network from config and skipping cleanly when a key is absent is the right shape — it means testnet-only deployments work with no mainnet secret present, which is what we want for contributors. And logging a warning rather than throwing when nothing is configured is the correct trade for a service where the facilitator is one feature among many.

@Anambraboi-1
Anambraboi-1 force-pushed the feat/x402-facilitator branch from c906ee4 to a69e196 Compare August 30, 2026 09:49

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

Good turnaround — three of the four are properly fixed:

  • @ts-ignore@ts-expect-error on both imports, so the suppressions remove themselves once the types resolve
  • internal failures now return 500 rather than 400, so an RPC outage on our side no longer tells a resource server that the payer's payload was bad
  • err.message no longer reaches the client, and req.log.error(err, …) keeps the detail where we can read it — that's exactly right, given our mainnet RPC endpoint is a paid provider key that error strings routinely embed

Two things left, and one of them is my fault to resolve rather than yours.

1. invalidReason was removed rather than corrected

return reply.status(500).send({
  isValid: false,
  invalidMessage: 'Internal server error during verification'
})

I asked whether 'internal_error' was a valid value in the x402 reason enum. The answer to that isn't to drop the field — it's to use a value the spec defines.

An x402 VerifyResponse with isValid: false and no invalidReason is very likely a schema violation, and a spec-compliant client may fail to parse it. I'm not certain of the v2 enum off-hand, so please check the spec rather than take my word: if there's a defined reason for a facilitator-side failure, use it; if there genuinely isn't one, keep a documented value and note the gap. Same for errorReason on /settle.

This matters more here than it normally would — the RFP is judged on spec conformance, and lens#127 is going to baseline exactly this response against the reference facilitator.

2. The /supported collision — my call to make, so here it is

This PR still registers /supported in src/api/facilitator.ts, and #143 still registers it in src/routes/facilitator.ts, both exporting registerFacilitatorRoutes.

Decision: /supported stays here. facilitator.getSupported() derives the answer from the schemes actually registered, so it can't advertise a network we have no key for. #143's version lists both networks unconditionally, which over-promises. I've asked @Elizabethxxx to narrow #143 to its tests and doc comments, which are the better half of that PR and which this one is thin on.

So: nothing to change here for the collision — just don't remove /supported in a later revision.

3. Needs a rebase

main moved today (#136, #137, #138, #139, #144 all landed) and GitHub now reports CONFLICTING. That's my merge ordering, not yours. #141 needs the same rebase and goes in first, since your first commit is #141 — rebase that one and this should mostly follow.

Offer stands from #141: if the conflicts get awkward, say so and I'll do the three-way merge locally.

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

All four resolved. Approving.

On invalidReason — I went and checked, and my concern was unfounded. The installed @x402/core declares:

invalidReason?: string

A free-form optional string, not a closed enum. So neither removing it nor restoring 'internal_error' was a spec violation, and I shouldn't have implied the removal was. Restoring it is still the better call — an optional field a caller can read beats one that's absent — but I sent you round a loop for something that turned out not to be a rule. Sorry for that.

Worth carrying forward: because it's optional and untyped, a client can't rely on it, so the log line you added is the thing actually carrying the diagnostic weight.

Verified locally, having learned this morning not to approve on a green badge alone:

tsc --noEmit   0 errors
Tests          253 passed | 1 skipped (254)
Test Files     35 passed  | 1 skipped (36)

That's up from 245 on main — your facilitator tests. Typecheck & build also passes now; its runs had been stuck in action_required (fork PRs need maintainer approval to run workflows), which is why this PR looked like it only had GitGuardian. That was a gap in my reading, not yours.

Recap of the four:

  • @ts-ignore@ts-expect-error, so the suppressions self-remove once the types land
  • internal failures return 500, not 400 — an RPC outage on our side no longer tells a resource server the payer's payload was bad
  • err.message no longer reaches the caller, and req.log.error(err, …) keeps the detail where we can read it. That one mattered: our mainnet RPC endpoint is a paid provider key, and error strings routinely embed it
  • /supported stays here, deriving from actually-registered schemes so it can't advertise a network we hold no key for

The /settle spend-ceiling discussion from my first review is now tracked as #147 rather than being lost — that's follow-up work against a funded mainnet fee payer, not a blocker on this.

Good, patient work through several rounds of this. Merging.

@Miracle656
Miracle656 merged commit da2fce1 into Miracle656:main Aug 30, 2026
2 checks passed
Miracle656 added a commit to ezedike-evan/Lens that referenced this pull request Sep 1, 2026
…entation

Miracle656#142 landed a POST /settle in src/api/facilitator.ts hours before this PR.
Both files exported registerFacilitatorRoutes and both registered POST
/settle, which would not compile and would have thrown
FST_ERR_DUPLICATED_ROUTE if it had.

Resolved by keeping this implementation, which is the better of the two: it
writes a SettlementAttempt row keyed on the inner transaction hash BEFORE
submitting, so a resource server that times out and retries replays the
stored answer instead of paying twice, and an attempt left in-flight is
resolved by reading the ledger rather than by resubmitting. The handler in
api/facilitator.ts had no idempotency at all.

- api/facilitator.ts keeps /supported and /verify; its /settle is removed,
  with a comment saying where the route went and why.
- routes/facilitator.ts exports registerSettleRoute, so the two modules no
  longer collide on a name.
- x402/facilitator.ts now sources keys from getNetworkConfig(network)
  .facilitator, the same place /supported and /verify read them, instead of
  a second FACILITATOR_SIGNER_SECRETS variable. One answer to which account
  settles on a network.
- SettlementAttempt.network stores the NetworkName, not the CAIP-2 id. Every
  other model in this schema uses testnet/mainnet, and a row tagged
  'stellar:testnet' would be invisible to a network filter written the way
  the rest of the schema expects. CAIP-2 stays on the wire.
- .env.example and settle-design.md now name the variables that are actually
  read; the ones they documented had become unreachable.
- The three /settle cases in facilitator.test.ts covered the removed handler
  and are superseded by facilitatorSettle.test.ts, which also covers replay,
  in-flight resolution and malformed payloads.

tsc clean; full suite 366 passed / 1 skipped.
Miracle656 added a commit that referenced this pull request Sep 1, 2026
… (#149)

* docs: design note for the x402 facilitator's POST /settle

Covers the four decisions #126 asks to have agreed before any
settlement code is written: which keys the facilitator holds, the
idempotency key and when its record is written, how a payload is
marked consumed, and how failures map onto SettleResponse.

No runtime change — the implementation follows once these are agreed.

* feat(x402): implement POST /settle on the facilitator

Wires @x402/stellar's ExactStellarScheme behind the route rather than
reimplementing verification or settlement, per the RFP. What this owns is
the surface: the wire contract, key handling, idempotency, replay and
failure mapping, as agreed in docs/x402/settle-design.md.

- no payer key is held; the facilitator holds fee and sequence-number
  keys only, and answers explicitly when none is configured
- the idempotency key is the inner transaction hash, derived from the
  payload, and its record is written before submission
- a second settle for the same payload replays the stored answer; an
  in-flight record resolves from the ledger, never by resubmitting
- failures keep the SDK's own errorReason values, and no rejection ever
  leaves errorReason null

* Merge main and resolve the /settle collision in favour of this implementation

#142 landed a POST /settle in src/api/facilitator.ts hours before this PR.
Both files exported registerFacilitatorRoutes and both registered POST
/settle, which would not compile and would have thrown
FST_ERR_DUPLICATED_ROUTE if it had.

Resolved by keeping this implementation, which is the better of the two: it
writes a SettlementAttempt row keyed on the inner transaction hash BEFORE
submitting, so a resource server that times out and retries replays the
stored answer instead of paying twice, and an attempt left in-flight is
resolved by reading the ledger rather than by resubmitting. The handler in
api/facilitator.ts had no idempotency at all.

- api/facilitator.ts keeps /supported and /verify; its /settle is removed,
  with a comment saying where the route went and why.
- routes/facilitator.ts exports registerSettleRoute, so the two modules no
  longer collide on a name.
- x402/facilitator.ts now sources keys from getNetworkConfig(network)
  .facilitator, the same place /supported and /verify read them, instead of
  a second FACILITATOR_SIGNER_SECRETS variable. One answer to which account
  settles on a network.
- SettlementAttempt.network stores the NetworkName, not the CAIP-2 id. Every
  other model in this schema uses testnet/mainnet, and a row tagged
  'stellar:testnet' would be invisible to a network filter written the way
  the rest of the schema expects. CAIP-2 stays on the wire.
- .env.example and settle-design.md now name the variables that are actually
  read; the ones they documented had become unreachable.
- The three /settle cases in facilitator.test.ts covered the removed handler
  and are superseded by facilitatorSettle.test.ts, which also covers replay,
  in-flight resolution and malformed payloads.

tsc clean; full suite 366 passed / 1 skipped.

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
Miracle656 added a commit to Elizabethxxx/Lens that referenced this pull request Sep 1, 2026
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 added a commit that referenced this pull request Sep 1, 2026
* feat: add facilitator GET /supported capability discovery route

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 #124

* Port this PR's tests and reasoning onto the /supported that shipped

#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. #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 #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.

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
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 POST /verify (including __check_auth contract accounts)

2 participants