Repository navigation
Harden the x402 facilitator: spend ceiling, caller allow-list, settle idempotency #147
Description
Part of #135 (SCF RFP epic). Follows #125 / #126 — this is the hardening those two leave open.
Background
POST /settle submits a Stellar transaction and, with areFeesSponsored: true, spends the facilitator's fee-payer XLM on every call. The route is registered config: { public: true }, which skips API-key auth.
The only thing bounding it today is the global limiter: 100 requests/minute per IP for unauthenticated callers. That is not nothing, but 100 sponsored transactions per minute per IP is a meaningful drain, and IP addresses are cheap.
This is fine while the fee payer holds testnet lumens. It is not fine the first time it points at a funded mainnet account, which is the direction the RFP work is going.
To be clear about what is not wrong: a facilitator's /settle is meant to be callable by resource servers, and x402 assumes that. The gap is that nothing bounds how much it can be made to spend.
What to build
Three independent controls. All three, not whichever is easiest.
1. Spend ceiling
A hard cap on facilitator outflow that does not depend on request counting:
- per-settlement maximum (reject a payment requiring more than
Xstroops in fees) - rolling daily total per network, tracked in Redis or Postgres so it survives a restart
- when the cap is hit: refuse further settles with a clear error, and log loudly. Degrading is correct; silently draining is not.
Per network — mainnet and testnet ceilings are not the same number.
2. Caller allow-list
An optional allow-list of resource servers permitted to settle (FACILITATOR_ALLOWED_ORIGINS, or signed caller identity). Empty means open, preserving today's behaviour, so this cannot break the demo path.
3. Settle idempotency
A replayed /settle for a payment already settled must return the original result rather than submitting a second transaction. Key on something derived from the payment payload — the nonce plus the authorisation entry, not a client-supplied id, which an attacker controls.
Key files
src/api/facilitator.ts(or wherever #125/#126 land)src/index.ts— rate-limit registration, for a tighter per-route limitsrc/config.ts— per-network config map, following the pattern in #113
Acceptance criteria
Each must be demonstrated by a test that fails without the control.
- A settle whose fee exceeds the per-settlement cap is rejected before any transaction is submitted
- Once the daily cap is reached, further settles are refused; the counter survives a process restart
- Caps are configured and enforced per network; exhausting testnet does not block mainnet
- With an allow-list configured, a caller outside it is refused; with it empty, behaviour is unchanged
- Replaying an identical settle returns the original result and submits exactly one transaction — assert the submission count, not just the response body
-
/settlehas its own rate limit, tighter than the global 100/min IP default - Refusals return a spec-valid x402 error shape, not a bare 500
- Every rejection path is logged with enough detail to tell abuse from misconfiguration
Notes
- Do not put the ceiling only in the rate limiter. Rate limits bound frequency; the thing being protected is a balance.
- Fail closed. A Redis outage must not mean unlimited settling — decide the behaviour deliberately and state it in the code.
- Worth checking against the upstream x402 spec whether a standard error code exists for "facilitator declined" before inventing one; the RFP is judged on spec compliance.
Drips Wave · Complexity: Advanced · 200 points
Required: Before submitting, join the contributor Telegram so your work can be tracked and counted toward the Stellar Wave: https://t.me/+fxHXq8f1SwlkZDBk
Activity
sudo-robi commentedon Aug 30, 2026
@sudo-robi has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @sudo-robi to this issue.
TechBroAfrica commentedon Aug 30, 2026
@TechBroAfrica has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @TechBroAfrica to this issue.
Clararosey67 commentedon Aug 30, 2026
@Kingsilva36 has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Kingsilva36 to this issue.
Daniel202251 commentedon Aug 30, 2026
@Daniel202251 has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Daniel202251 to this issue.
arandomogg commentedon Aug 30, 2026
@arandomogg has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @arandomogg to this issue.
alaminharuna-dev commentedon Aug 30, 2026
@alaminharuna-dev has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @alaminharuna-dev to this issue.
Adeolu01 commentedon Aug 30, 2026
@Adeolu01 has applied to work on this issue as part of the Stellar Wave Program's 8th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Adeolu01 to this issue.
sudo-robi commentedon Aug 31, 2026
@Miracle656 please the wave is about to end
therealbibson commentedon Sep 23, 2026
@therealbibson has applied to work on this issue as part of the Stellar Wave Program's 9th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @therealbibson to this issue.
drips-wave commentedon Sep 23, 2026
Congratulations, @therealbibson! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on September 30, 2026.
🧑💻 @therealbibson: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
Miracle656 commentedon Oct 16, 2026
@therealbibson — heads up before you see the assignment change, and please read this before worrying.
Your PR #159 is open and this issue is yours. Nothing about that has changed.
The mechanical problem: this issue was assigned on GitHub outside the Drips Wave, and the Wave will not let us add an already-assigned issue to the programme. That means that as things stand, when #159 merges you close a 200-point issue and earn nothing for it. That is not the outcome anyone wants.
So I am going to unassign you on GitHub for a few minutes, add this issue to the Wave, and then approve your claim so it comes straight back to you. Please apply for it on Drips as soon as you see this so the gap is as short as possible. If anyone else applies in the meantime I will decline it — your PR predates all of this and the work is plainly yours.
Doing the same for #166, #174 and #175, which are in the same position.
Your other two PRs, #158 and #160, are in the review queue and I am getting to them today.
Miracle656 commentedon Oct 16, 2026
@CollinsKRO and @Wittig18 — please do not start work on this one. Read this before you write any code, because I do not want either of you losing an evening to it.
This issue already has a complete, open pull request: #159 by @therealbibson, submitted on 12 October. It implements the per-settlement fee ceiling, the rolling daily spend ceiling, the caller allow-list and the rate limit. It is in my review queue today.
The issue looks unclaimed because of a problem on my side, not because it is free. It was assigned on GitHub outside the Wave, which meant it could not be added to the Wave programme, which meant the person who had already done the work would have earned nothing when it merged. To fix that I unassigned them so it could be added — and in the hour that gap was open, your two applications arrived. That is my mistake in sequencing, and I am sorry for the wasted attention.
Both of your applications are declined for that reason alone, and not for anything about the applications themselves.
@therealbibson — this is now in the Wave and waiting on you. Please apply through Drips as soon as you see this and I will approve it straight away. Your PR predates all of this and the work is plainly yours.
Where to go instead. Only seven Wave issues in this repo are genuinely unclaimed right now, and these are the ones with no assignee and no PR:
#129 and #130 are the Bazaar discovery pair and are also open, though they are the hardest in the track.
Supply is the constraint here, not demand — more issues are being published to this repo continuously, so it is worth watching the label rather than racing for the last few. Apologies again for the confusion; it was caused by me, not by either of you.
Miracle656 commentedon Oct 16, 2026
Correction to my earlier comments on this issue — please read, because I got this wrong.
Earlier today I said PR #159 was finished work for this issue, that it was "plainly yours", and I unassigned @therealbibson so the issue could be added to the Wave specifically so that the work would be paid for when it merged.
I have now read the branch rather than the file list, and #159 does not implement this issue. Its head carries one commit — the pair-issuer matching fix — plus 23 unrelated files (FontAwesome fonts, a
.vscodedirectory). That fix is already onmain, merged as41b1f62on 8 September, and the source is byte-identical to the copy on the branch. Merging #159 today would touch 123 files and revert work that has landed since.I based my earlier reading on the PR description and on GitHub's changed-files list, which shows what a branch changed since its merge base — not what merging it would do now. Both told a story the commit does not support. That is my mistake and I am sorry for the noise it caused on this issue.
Where that leaves things:
mainand apply through Drips; I will review it the day it arrives, and there is nothing held against you.Details are on #159.
feat(facilitator): harden x402 facilitator with spend ceiling, caller…
fix(facilitator): extract authoritative XDR amount, support apiKey.id…
feat(facilitator): harden x402 facilitator with spend ceiling, caller…
fix(facilitator): fail-closed unverified amounts, strictly auth calle…
feat(facilitator): spend ceiling on an amount the payer cannot choose (…