feat(x402): POST /settle — design note and implementation (closes #126) - #149
Conversation
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 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! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
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:
SettlementAttemptwith@@unique([network, txHash])as the lock- the record written before submission, so a crash between submit and write can't lose the attempt
submittingrows 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 PRTwo 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, returningnullwhen unconfigured — that's a cleaner seam than what's onmain) - move your handler into the existing
src/api/facilitator.ts, replacing the current/settlebody - drop
src/routes/facilitator.tsand its registration - keep the
SettlementAttemptmodel, the design note, and all offacilitatorSettle.test.ts—mainhas 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.prismamoved under you: #141 landed thenetworkdiscriminator, and every model now carriesnetworkwith@default("testnet"). Your@@unique([network, txHash])fits that convention exactly, so this should be additive — just make sureSettlementAttemptalso gets the default, orprisma db pushwill 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
left a comment
There was a problem hiding this comment.
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-Keyheader 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
submittingmeans 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' forNOT_FOUNDis 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:
routes/facilitator.tsexportsregisterSettleRoute, so the two modules stop colliding on a name;api/facilitator.tskeeps/supportedand/verifyand carries a comment saying where/settlewent.x402/facilitator.tsnow reads keys fromgetNetworkConfig(network).facilitator— the same place/supportedand/verifyread them — instead of introducing a secondFACILITATOR_SIGNER_SECRETSvariable. Two ways to configure the settlement key is a configuration bug waiting to be filed.SettlementAttempt.networknow stores theNetworkName, not the CAIP-2 id. This is the one substantive correction. Every other model in the schema usestestnet/mainnet; a row taggedstellar:testnetwould 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..env.exampleandsettle-design.mdnow name the variables that are actually read. Documenting unreachable env vars is worse than documenting none — someone setsFACILITATOR_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#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.
* 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>
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.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)ExactStellarSchemetakes, and the optionalfeeBumpSigner. Neither can move user funds, and the SDK's ownvalidateSimulationEventsrejects 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/settlefails 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. TheSettlementAttemptrow 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.
/verifyis side-effect free, so/settleis what burns a payload. A payload is consumed the moment its record exists:settledreplays the stored response verbatim,failedreplays the stored failure without resubmitting, andsubmittingis 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 is200+success: false, not a 4xx:HTTPFacilitatorClientraises 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 theSettleResponseshape. No rejection ever leaveserrorReasonnull — 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:
ExactStellarSchemefrom@x402/stellaris registered on anx402Facilitatorand the payload is handed to it untouched (there is a test asserting exactly that). SEP-41 handling, 7-decimal amounts,signatureExpirationLedgerbounds 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.ts—POST /settle, public (a facilitator cannot demand payment to accept one). Also exportsderiveIdempotencyKey.prisma/schema.prisma—SettlementAttempt, 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 throughapp.injectagainst real signed envelopes built with@stellar/stellar-sdk, so the hashing is real and not stubbed: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 — andsettleis never called in any of thoseC…contract-account payload takes the same path as aG…oneSettleResponseshape, with no record writtensettlethrowing →unexpected_settle_errorand the row finalised failednpx vitest run— 267 passed, 1 skipped (36 files).npx tsc --noEmitclean (afternpx prisma generate; the committed client is stale onmainfor unrelated models). No lint script in this repo.Two notes on merge order
src/routes/facilitator.tsis created here because x402 facilitator: implement GET /supported #124 hasn't merged. If facilitator: implement GET /supported #143 lands first, this route folds into that file — happy to rebase either way.