Skip to content

feat(x402): POST /settle — design note and implementation (closes #126) - #149

Merged
Miracle656 merged 4 commits into
Miracle656:mainfrom
ezedike-evan:docs/x402-settle-design
Sep 1, 2026
Merged

feat(x402): POST /settle — design note and implementation (closes #126)#149
Miracle656 merged 4 commits into
Miracle656:mainfrom
ezedike-evan:docs/x402-settle-design

Conversation

@ezedike-evan

@ezedike-evan ezedike-evan commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

closes #126

The issue asks for the four decisions to be agreed before settlement code exists, so this PR leads with the design note (docs/x402/settle-design.md) and implements exactly what it describes. The note is the part to argue with — if a decision in it is wrong, the code following it is wrong, and it is cheap to change now.

"Open a PR with a short design note first and get it agreed before implementing. […] the decisions in it need to be made in the open."

The four decisions

1. Keys. No payer key, ever — the payer authorises through a signed Soroban auth entry inside paymentPayload.payload.transaction. Two keys exist and both are fee/sequence-number only: the settlement signer(s) ExactStellarScheme takes, and the optional feeBumpSigner. Neither can move user funds, and the SDK's own validateSimulationEvents rejects a payload in which a facilitator address participates in the transfer, so non-custodial is a property of the flow rather than a claim. Secrets live in env (FACILITATOR_SIGNER_SECRETS, FACILITATOR_FEE_BUMP_SECRET), are read once at init, and are never persisted, logged or returned. With none configured, getFacilitator() returns null and /settle fails explicitly instead of half-working. Fee ceiling is configurable (FACILITATOR_MAX_FEE_STROOPS, default 50,000), not hard-wired.

2. Idempotency. The key is the inner transaction hash — TransactionBuilder.fromXDR(transaction, passphrase).hash() — derived from the payload rather than generated by us, identical to the hash the ledger will record, and network-scoped for free because the passphrase is mixed into it. The SettlementAttempt row is written before submission; its @@unique([network, txHash]) is the lock, so two concurrent settles race to insert and exactly one proceeds. That holds across replicas, which an in-process mutex would not.

3. Replay. /verify is side-effect free, so /settle is what burns a payload. A payload is consumed the moment its record exists: settled replays the stored response verbatim, failed replays the stored failure without resubmitting, and submitting is resolved by reading the ledger — never by submitting again, since a row in that state means we genuinely do not know whether the network took it.

4. Failure semantics. The SDK's own reasons are returned unaltered (settle_exact_stellar_transaction_submission_failed, ..._transaction_failed, unexpected_settle_error, …), never rewritten into a vocabulary of ours. A failed payment is 200 + success: false, not a 4xx: HTTPFacilitatorClient raises a transport error on a non-2xx body it cannot recognise, so the reference client only reads failures cleanly in that shape. 4xx is reserved for a request we cannot parse, and even then the body keeps the SettleResponse shape. No rejection ever leaves errorReason null — that is an RFP hard criterion and it is asserted in a test rather than claimed.

What was built on, and what was not

Per the RFP correction in the issue, no verification or settlement logic is reimplemented: ExactStellarScheme from @x402/stellar is registered on an x402Facilitator and the payload is handed to it untouched (there is a test asserting exactly that). SEP-41 handling, 7-decimal amounts, signatureExpirationLedger bounds and fee sponsorship are the SDK's, not ours.

Files

  • docs/x402/settle-design.md — the note: custody table, idempotency key rationale, replay states, and a failure table mapping every situation to HTTP status / success / errorReason.
  • src/x402/facilitator.ts — configuration and keys only; memoised per network; returns null when unconfigured.
  • src/routes/facilitator.tsPOST /settle, public (a facilitator cannot demand payment to accept one). Also exports deriveIdempotencyKey.
  • prisma/schema.prismaSettlementAttempt, with the unique identity that does the locking.
  • src/index.ts, .env.example — wiring and the three new keys.

Tests

src/__tests__/facilitatorSettle.test.ts (22), driving the route through app.inject against real signed envelopes built with @stellar/stellar-sdk, so the hashing is real and not stubbed:

  • the same payload settled twice submits once and the second response equals the first
  • the record exists before submission — asserted from inside the settle spy, not after the fact
  • a stored failure replays instead of retrying
  • an in-flight (submitting) record resolves from the ledger: SUCCESS finalises it settled, FAILED finalises it failed, NOT_FOUND answers "still in flight" with a non-null reason — and settle is never called in any of those
  • a C… contract-account payload takes the same path as a G… one
  • pubnet as well as testnet, including that the key is derived under the right passphrase
  • malformed body, unknown network, and non-envelope payload → 400, still in the SettleResponse shape, with no record written
  • no key configured → explicit failure, no record written
  • settle throwing → unexpected_settle_error and the row finalised failed
  • a failure with no reason from the SDK is normalised to a non-null one; a failure that has one keeps it verbatim

npx vitest run — 267 passed, 1 skipped (36 files). npx tsc --noEmit clean (after npx prisma generate; the committed client is stale on main for unrelated models). No lint script in this repo.

Two notes on merge order

Covers the four decisions Miracle656#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.
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
@ezedike-evan ezedike-evan changed the title docs: design note for the x402 facilitator's POST /settle (#126) feat(x402): POST /settle — design note and implementation (closes #126) Aug 30, 2026
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@ezedike-evan 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 design work here is the best thing in the facilitator so far, and I want to be clear about that before the blocker, because the blocker is timing rather than quality.

What you built is better than what is currently on main. #142 merged about two hours ago with a /settle that verifies and submits. Yours adds the part that actually makes settlement safe to call twice:

  • SettlementAttempt with @@unique([network, txHash]) as the lock
  • the record written before submission, so a crash between submit and write can't lose the attempt
  • submitting rows resolved from the ledger, never by resubmission
  • terminal failures replaying their failure instead of getting a second submission
  • idempotency keyed on the inner transaction hash rather than a caller-supplied header — the caller controls the header, so keying on it would let an attacker force a duplicate submission

That last decision is the one I'd have argued for and the reasoning in the design note is right. It's also, in effect, the idempotency half of #147, which I filed this morning before this PR existed.

Blocker: it collides with what merged

main now has /supported, /verify and /settle in src/api/facilitator.ts. This PR adds a second POST /settle in a new src/routes/facilitator.ts, and src/index.ts ends up with:

import { registerFacilitatorRoutes } from './api/facilitator'    // main
import { registerFacilitatorRoutes } from './routes/facilitator' // this PR

Two problems, both fatal rather than cosmetic: a duplicate identifier that won't compile, and — if it did — Fastify raising FST_ERR_DUPLICATED_ROUTE for POST /settle at startup. The service wouldn't boot.

None of this is your fault. You opened against a main that didn't have #142 yet, and #126 explicitly asked for a design note first, which is why yours took longer to arrive than the implementation it now overlaps.

What I'd like instead

Rebase onto main and let your /settle replace the one in src/api/facilitator.ts, rather than adding a parallel route.

Concretely:

  • keep src/x402/facilitator.ts (config/keys, returning null when unconfigured — that's a cleaner seam than what's on main)
  • move your handler into the existing src/api/facilitator.ts, replacing the current /settle body
  • drop src/routes/facilitator.ts and its registration
  • keep the SettlementAttempt model, the design note, and all of facilitatorSettle.test.tsmain has nothing equivalent

The result should be a smaller diff that strictly improves the merged /settle. If that rework is more than you want to take on, say so and I'll do the port myself with attribution to you — I'd rather ask than assume.

Two notes for the rebase

  • prisma/schema.prisma moved under you: #141 landed the network discriminator, and every model now carries network with @default("testnet"). Your @@unique([network, txHash]) fits that convention exactly, so this should be additive — just make sure SettlementAttempt also gets the default, or prisma db push will refuse on a populated database. That specific mistake cost #141 a round.
  • Your note that a published settled transaction hash per network is still outstanding is correct and worth keeping in the PR body. It needs funded keys, which is deployment rather than code — but it is the RFP's acceptance criterion, so it shouldn't quietly disappear.

Genuinely good work. The four decisions restated at the end of the design note are exactly the right way to make a reviewer's job possible.

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

Approving and merging. I did the port myself (251ee2d) rather than leave this open — the wave has closed and this implementation is too good to lose to a timing accident.

The blocker was never the code; it was that #142 landed a POST /settle a few hours ahead of you. Both modules exported registerFacilitatorRoutes and both registered POST /settle — a duplicate identifier that would not compile, and FST_ERR_DUPLICATED_ROUTE if it had. I resolved it in favour of yours, because on the merits it is the better of the two:

  • The record is written before submission, not after. This is the entire difference between an idempotency scheme that works and one that looks like it does. Writing after settlement leaves a timeout with no trace, and the retry pays again.
  • The key is derived from the payload, not supplied by the caller. An Idempotency-Key header only works if the client remembers to send the same one — the failure mode is silent and the client controls it. Hashing the inner transaction means a resource server that retries after a timeout lands on the same key without having to know idempotency exists. It is also the hash the ledger records, which is what makes the next point possible.
  • An in-flight row is resolved by reading the ledger, never by resubmitting. A row stuck in submitting means the process died between writing and recording, so you genuinely do not know whether the network took the transaction. Asking the ledger is the only answer that cannot double-pay. Returning 'still in flight, retry with the same payload' for NOT_FOUND is right too — it is honest about uncertainty instead of guessing.
  • The unique constraint is the lock. Two concurrent settles race to insert, one wins, the loser replays. No advisory lock, no application-level mutex that a second process would not share.

The handler in api/facilitator.ts had none of this — it called settle and returned the result. That is the version I removed.

Four changes I made on top of your work, each with a reason:

  1. routes/facilitator.ts exports registerSettleRoute, so the two modules stop colliding on a name; api/facilitator.ts keeps /supported and /verify and carries a comment saying where /settle went.
  2. x402/facilitator.ts now reads keys from getNetworkConfig(network).facilitator — the same place /supported and /verify read them — instead of introducing a second FACILITATOR_SIGNER_SECRETS variable. Two ways to configure the settlement key is a configuration bug waiting to be filed.
  3. SettlementAttempt.network now stores the NetworkName, not the CAIP-2 id. This is the one substantive correction. Every other model in the schema uses testnet/mainnet; a row tagged stellar:testnet would be invisible to a network filter written the way the rest of the schema expects — silently, returning zero rows rather than erroring. CAIP-2 is a wire format and stays on the wire, including in the response bodies, which are unchanged.
  4. .env.example and settle-design.md now name the variables that are actually read. Documenting unreachable env vars is worse than documenting none — someone sets FACILITATOR_SIGNER_SECRETS, gets no settlement, and has no way to tell why.

The three /settle cases in facilitator.test.ts covered the handler I removed; they are superseded by your 22, which also cover replay, in-flight resolution, contract-account authorisation and malformed payloads. Your tests build a real signed envelope and let the route hash it rather than stubbing the hash — that is why I could make change 3 with confidence.

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

This is effectively the idempotency half of #147, written before that issue existed. Sorry it took a maintainer port to land.

@Miracle656
Miracle656 merged commit b5b1e3d into Miracle656:main Sep 1, 2026
2 checks passed
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 /settle (design note first)

2 participants