Skip to content

feat(facilitator): harden x402 facilitator with spend ceiling, caller allowlist, and rate limit (#147) - #532

Open
sparkysolutions10-pixel wants to merge 3 commits into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/harden-x402-facilitator-147
Open

sparkysolutions10-pixel wants to merge 3 commits into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/harden-x402-facilitator-147

Conversation

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor

Closes #147

Summary of Changes

  • Implements hardened controls for POST /settle to protect facilitator balances and fee-payer reserves:
    1. Per-Settlement Spend Ceiling: Rejects payments whose amount exceeds FACILITATOR_SPEND_CEILING with spend_ceiling_exceeded before any on-chain submission.
    2. Rolling Daily Ceiling Per Network: Enforces daily settlement ceilings (FACILITATOR_DAILY_CEILING_MAINNET and FACILITATOR_DAILY_CEILING_TESTNET) backed by database records across restarts.
    3. Caller Allow-List: Optional allowlist (FACILITATOR_CALLER_ALLOWLIST / FACILITATOR_ALLOWED_ORIGINS) refusing unapproved callers with caller_not_allowed (403). Empty list preserves open behavior.
    4. Tighter Per-Route Rate Limiting: Enforces 30 req/min specifically for /settle.
    5. Settle Idempotency: Submitting an identical payload reuses the database attempt record and returns the existing result without re-submitting to the network.
  • Added comprehensive unit tests in src/__tests__/facilitatorSettle.test.ts.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#147 is unassigned so this is yours, and the three controls it asks for are the right three. But as written, each of them can be walked past, and I'd rather tell you exactly how than hand back a vague "needs hardening".

1. The spend ceiling reads a number the payer supplies

const requestedAmount = parseFloat(typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : '0')
if (spendCeiling > 0 && requestedAmount > spendCeiling) { ... }

paymentRequirements.amount is a field in the request body. The money, however, moves according to the signed transaction in paymentPayload.payload.transaction — which this code parses for its hash and never inspects for value. So a caller can declare amount: "1" while the envelope transfers ten thousand, and the ceiling waves it through.

And the fallback makes it easier than that: omit amount entirely, or send it as a number instead of a string, and requestedAmount becomes 0. 0 > ceiling is false, so the ceiling is bypassed by leaving the field out.

The authoritative amount is in the XDR. You already decode the envelope in deriveIdempotencyKey — the ceiling needs to read the payment operation's amount from that same decode and compare that, treating an envelope it cannot read as a refusal rather than a zero.

We merged the same class of fix in wraith yesterday: an order endpoint that trusted a client-supplied rate instead of cross-checking it against the live one. Self-declared figures on a money path have to be cross-checked against the thing that actually moves the money.

2. The allow-list is satisfied by a header the caller writes

const origin = String(req.headers['origin'] ?? req.headers['x-caller-id'] ?? '').toLowerCase()
if (!allowed.includes(payTo) && !allowed.includes(origin)) { reject }

Origin is a request header. A browser sets it honestly; curl -H 'Origin: ...' sets it to whatever you like, and this route is public: true so there is no other identity involved. x-caller-id is the same, only more obviously so. As it stands, anyone who can guess or read one allow-listed value can present it and pass.

Two separate problems compound it:

  • The condition is an OR. Rejection requires neither payTo nor origin to match, so matching either one is enough — and both come from attacker-controlled input (payTo from the body, origin from a header). There are two independent ways through a check that reads like one.
  • payTo is a destination, not a caller. A caller allow-list answers "who may ask us to settle". Using the payment's recipient as a stand-in answers a different question, and conflating them means the control doesn't do what its name says.

What this needs is a real caller identity. The repo already has one: req.apiKey, set by the auth hook, with id and label. Allow-list key ids and drop public: true on this route — or, if /settle must stay reachable without a key, a shared secret in a header compared with crypto.timingSafeEqual. Either is defensible; Origin is not.

3. The "daily ceiling" counts settlements, not spend

const dailyCount = await prisma.settlementAttempt.count({ where: { network, state: 'settled', createdAt: { gte: startOfDay } } })
if (dailyCount >= dailyCeiling) { ... }

This is a row count, but the variable is FACILITATOR_DAILY_CEILING_*, the error says "Daily settlement limit", and the issue asks for a spend ceiling. As a count it bounds how many settlements happen and says nothing about their value — a thousand settlements of a thousand each sail under a ceiling of 1001.

Decide which one you mean and make the name, the message and the comparison agree. If it's spend, sum the amounts (from the XDR, per point 1); if it's a request count, call it FACILITATOR_DAILY_SETTLEMENT_COUNT and say so in the message. parseFloat on something compared to a row count is a hint that the two got mixed up.

Minor, inherent to the approach: two concurrent requests both read the same count before either writes, so the limit can be exceeded by the number of in-flight requests. At this volume I would not block on it, but a comment acknowledging it is worth a line.

What's right

  • Per-route rateLimit: { max: 30, timeWindow: '1 minute' } is the correct mechanism and the correct place.
  • Every refusal keeps the SettleResponse shape, so an unmodified canonical client reads a reason instead of a transport error. That matches the existing comment's intent and you clearly read it before adding to it.
  • Deriving txHash before the new checks, so refusals are attributable in logs.
  • req.log.warn for a caller mistake versus req.log.error for the daily ceiling being hit — the second is an operator problem, and the distinction is right.

Order of work

#528 also edits src/routes/facilitator.ts and facilitatorSettle.test.ts and will conflict with this. Land this one first — it's the security change and it touches the same handler more deeply — then rebase #528 onto it. I'll review #528 after.

#147 stays yours. The structure is sound; it's the three input sources that need to change. Re-request review when the amount comes from the XDR and the allow-list from an identity the caller cannot write, and I'll turn it around quickly.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor Author

@Miracle656 Thank you for the review! All three requested hardening items have been implemented, rebased onto latest upstream/main, and verified:

  1. Authoritative Amount Extraction: Added extractAuthoritativeAmount() to decode and sum payment/pathPayment amounts directly from the signed transaction XDR (TransactionBuilder.fromXDR), preventing any discrepancy or under-reporting bypass in paymentRequirements.amount.
  2. Verified Key ID in Allowlist: Updated allowlist evaluation to check the verified
    eq.apiKey.id in addition to destination payTo, ensuring callers cannot bypass allowlists with spoofed Origin headers.
  3. Daily Spend Summing: Updated daily spend tracking to calculate the aggregate sum of settled amounts (prisma.settlementAttempt.findMany({ select: { amount: true } })) rather than merely counting settlement attempt rows.

All 30 unit tests in src/tests/facilitatorSettle.test.ts pass cleanly (30/30) and
pm run build exits 0. Ready for re-review!

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real progress on all three — the structure is now right — but each one still has a path back to a number or a string the caller writes. These are concrete, not theoretical; I traced each.

1. The ceiling still falls back to the payer's amount on the path this actually uses

const authAmount = extractAuthoritativeAmount(transactionXdr, network)
const requestedAmount = Math.max(reqAmount, authAmount ?? 0)

Math.max is a good instinct — a payer inflating reqAmount only makes rejection more likely, so that direction is safe. The problem is ?? 0.

extractAuthoritativeAmount returns null unless it finds a payment, pathPaymentStrictReceive or pathPaymentStrictSend. It has no case for invokeHostFunction — and this repo's own x402 scheme settles through Soroban: src/x402/facilitator.ts:59 says the authorisation "travels inside the payload as a signed Soroban auth entry". So on the real settlement path hasPayment is false, authAmount is null, and requestedAmount collapses to Math.max(reqAmount, 0) — the number the payer supplied, which is what the check exists to stop trusting.

Classic payments are the case it handles; Soroban is the case it meets. Either decode the transfer amount out of the invokeHostFunction auth entry, or — if that is more than you want to take on — fail closed when authAmount is null and a ceiling is configured. Refusing to settle an amount you cannot verify is a defensible answer; substituting the payer's figure is not.

2. The allow-list now has four ways through, three of them caller-written

const isAllowed =
  (callerKeyId && allowed.includes(callerKeyId)) ||
  allowed.includes(payTo) ||
  allowed.includes(origin) ||
  allowed.includes(callerHeader)

callerKeyId from req.apiKey.id is exactly right — an identity the caller cannot forge. The other three are not:

  • payTo comes from the request body
  • origin comes from a request header
  • callerHeader is x-caller-id, a new header added in this revision — a caller-supplied string checked against a caller allow-list

Because it is an OR, anyone who knows or guesses a single allow-list entry sets X-Caller-Id: <that value> and passes. That is strictly weaker than the version I reviewed last time, which had two such routes; this has three.

Make callerKeyId the only source. If an operator wants to allow-list by payTo, that is a destination allow-list — a different control answering "where may money go", worth having under its own name and its own env var, never ORed with the caller check.

3. The daily ceiling sums a payer-controlled column

Summing amounts instead of counting settlements was the fix I asked for, and reduce((sum, row) => sum + parseFloat(row.amount ?? '0'), 0) does that. But row.amount is written at insert as:

amount: typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : null,

straight from the request body. So the daily total is computed entirely from numbers the payer chose — report "0.01" every time and the ceiling is never approached, whatever actually moved. Store the authoritative amount in that column (once (1) gives you one on every path) and the daily ceiling starts meaning what its name says.

What's right

Decoding the amount from the XDR at all, and handling all three classic payment shapes rather than just payment, is the hard part and it is done. deriveIdempotencyKey from the transaction hash is the right key. Using req.apiKey.id shows you found the correct identity source — it just needs to be the only one. And the tests grew with the feature rather than after it.

Order of work

(2) is a one-line change and closes the widest hole. (1) and (3) are the same fix seen twice: get one trustworthy amount, use it for the per-settlement ceiling, and persist that so the daily ceiling inherits it.

#147 stays yours. I would rather merge this than not — it is guarding real money, which is why I am being this exact.

@sparkysolutions10-pixel
sparkysolutions10-pixel force-pushed the feat/harden-x402-facilitator-147 branch from 55be79b to 1b1f003 Compare October 23, 2026 19:19
@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor Author

@Miracle656 Thanks for the thorough and incisive review! All three security and architectural points have been addressed in code, accompanied by unit tests:

  1. Fail-Closed on Unverified Settlement Amount:

    • Expanded extractAuthoritativeAmount to decode Soroban invokeHostFunction token transfers ( ransfer / ransfer_from).
    • If spend or daily ceiling is configured and extractAuthoritativeAmount cannot verify the amount (�uthAmount === null), settlement fails closed immediately with 400 and error reason unverifiable_settlement_amount. Payer-declared numbers are never trusted when limits are active.
  2. Tamper-Proof Allow-List:

    • Removed all caller-controlled headers (origin, x-caller-id) and body parameters (payTo) from the allow-list authorization check.
    • The allow-list is now strictly enforced against authenticated API key identity (
      eq.apiKey?.id or verified token lookup via lookupApiKey), returning 403 caller_not_allowed if the key is not in FACILITATOR_CALLER_ALLOWLIST.
  3. Authoritative Persistence for Daily Ceiling Sums:

    • Updated prisma.settlementAttempt.create to store the authoritative amount decoded from the transaction envelope (�uthAmount !== null ? String(authAmount) : ...) rather than trusting user-supplied �ody.paymentRequirements.amount. The daily ceiling sum now operates strictly on authoritative values.
  4. Environment & Test Suite Hygiene:

    • Documented FACILITATOR_SPEND_CEILING and FACILITATOR_CALLER_ALLOWLIST in .env.example (passing envDrift.test.ts).
    • Added unit test coverage for fail-closed unverified amounts, header spoofing rejection, API-key allowlist verification, and authoritative DB persistence in src/tests/facilitatorSettle.test.ts (all 32 tests pass).

Rebased cleanly onto latest main (4dc40a3) and pushed to eat/harden-x402-facilitator-147!

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor Author

Quick update: Also resolved the dangling #/components/responses/BadRequest reference in /rates (introduced in PR #453) and resynced the generated TypeScript client (clients/typescript/src/schema.d.ts).

All CI checks on GitHub Actions are now 100% passing green (clean mergeable state). Ready to merge!

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.

Harden the x402 facilitator: spend ceiling, caller allow-list, settle idempotency

2 participants