Repository navigation
feat(x402): harden facilitator settle — spend ceiling, allow-list, rate limit - #159
therealbibson wants to merge 8 commits into
Conversation
|
@therealbibson 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.
Several controls here are done right, and on a money path that is worth saying first: the allow-list uses allowed.includes(identity) — exact match, not substring; the daily reserve sits after the idempotency check so a replay cannot double-charge; and reserveDailySpend catches store errors and refuses, i.e. fail-closed. It is also genuinely wired — @fastify/rate-limit registers at src/index.ts:115, before registerSettleRoute at :161, so the route-level override takes effect.
Three things block it.
1. It ships a test that fails deterministically
src/__tests__/facilitatorHarden.test.ts — "registers a /settle rate limit tighter than the global 100/min default":
→ expected undefined to be defined
Tests 1 failed | 15 passed (16)
Failed on both full runs. It reads (app as any).routes, which is not a Fastify API — it is undefined, ?? [] makes it empty, and max is undefined. The test app also never registers the rate-limit plugin.
Either drop the assertion or register the plugin in buildApp() and assert behaviourally — fire 21 requests, expect a 429. The latter actually tests the thing.
2. The spend ceiling fails OPEN on a typo'd env var
src/config.ts, the new facilitatorDailySpendCeilingStroops block: parseInt(process.env[...] || default, 10) yields NaN for a non-numeric value, and newTotal > NaN is false — so the cap is silently never enforced.
"" -> 100000000 | enforced? true
"abc" -> NaN | enforced? false <-- cap disabled, no error
"1e8" -> 1 | enforced? true <-- bricks settlement
"100000000" -> 100000000 | enforced? true
On a money path a mistyped FACILITATOR_DAILY_SPEND_STROOPS_MAINNET turns off the control you are adding, with nothing in the logs. And 1e8 — a natural way to write 100000000 — parses to 1.
Validate with Number.isSafeInteger(n) && n > 0, else fall back to the default and log loudly. A safety control that silently disables itself is worse than no control, because it is believed.
3. Internal infrastructure leaks to an unauthenticated caller
settleFailure (src/routes/facilitator.ts:83-92) puts errorMessage straight into the JSON body, and /settle is config: { public: true }. reserveDailySpend's catch builds:
`Spend ledger unavailable; refusing settle (fail-closed): ${(err as Error).message}`An ioredis failure is typically connect ECONNREFUSED <host>:<port> — internal topology handed to any anonymous caller. Your own test confirms errorMessage reaches the body. Send a fixed string to the client, keep err.message in req.log.
Worth raising, not blocking
- The allow-list is not an auth control.
resolveCallerIdentitytrusts theOriginheader and falls back tox-facilitator-caller— both trivially spoofable by any non-browser client (curl -H "Origin: https://allowed.example"). Fine as a misconfiguration guard; the.env.examplewording should say that rather than implying access control. - Fail-closed becomes fail-permanent. The attempt row is created before
reserveDailySpend, so onstore_unavailableit is finalised asfailed— and the idempotency check then replays that stored 403 forever. A transient Redis blip permanently bricks that payment payload even after recovery. - Reservation leak. If
store.expirethrows afterincrbysucceeded, the catch returns without rolling back theincrby, and the key now has no TTL — so the leaked reservation never expires. - Test fixture
payTo: 'G' + 'A'.repeat(55)is not a valid address. Inert here (never passed toAddress.fromString), but it is another invented one. Theassetcontract id does validate.
tsc --noEmit clean, merges clean with main.
…te limit behaviourally
|
All three blockers from the review:
|
Miracle656
left a comment
There was a problem hiding this comment.
Re-reviewed at c38ab03. All three blockers are properly fixed — thank you, and the rate-limit test in particular is better than what I asked for. I am not merging yet: the merge result is red against a guard main has gained since, and while reading the upstream scheme to check your fee accounting I found that the ceiling is counting the wrong number. Both are specific and neither needs guessing.
Fixed
| # | Point | Status |
|---|---|---|
| 1 | facilitatorHarden.test.ts rate-limit test fails deterministically |
Fixed |
| 2 | Spend ceiling fails OPEN on a typo'd env var | Fixed |
| 3 | Store error leaked to an unauthenticated caller | Fixed |
1. buildApp() now registers @fastify/rate-limit and the assertion fires 21 requests and expects exactly one 429 at index 20. That is the behavioural test, and expect(app.hasRoute(...)) first is a nice touch — it fails for the right reason if the route ever moves. Verified on the merge result:
$ npx vitest run src/__tests__/facilitatorHarden.test.ts src/__tests__/facilitatorSettle.test.ts
Test Files 2 passed (2)
Tests 38 passed (38)
2. Number() + Number.isSafeInteger(n) && n > 0 + console.warn + fall back to the default. "1e8" now parses to 100000000 instead of 1, and "abc" no longer silently disables the cap. The comment explaining why is the part I most wanted and it is there.
3. reserveDailySpend's catch now sends a fixed 'Spend ledger unavailable; refusing settle (fail-closed).' and keeps the driver error in console.error. Your own test pins the message shape (toMatch(/Daily facilitator spend ceiling/)), so a regression would be caught.
Blocking — the daily ledger charges a number the facilitator never pays
This is the one that matters, and it is not something you could have seen without reading the scheme's source, so: extractFeeStroops reads tx.fee out of the caller-supplied envelope, and reserveDailySpend(network, feeStroops, ceiling) books that against the ceiling. But @x402/stellar's ExactStellarScheme.settle() throws that envelope away and rebuilds the transaction on the facilitator's own account:
const facilitatorAccount = await server.getAccount(signer.address);
const rebuiltTx = new TransactionBuilder(facilitatorAccount, {
fee: BASE_FEE,
networkPassphrase,
…
sorobanData
}).setTimeout(…).addOperation(Operation.invokeHostFunction(invokeOp)).build();and verify() derives the real number from its own simulation:
const minResourceFee = parseInt(simResponse.minResourceFee, 10);
const settlementFeeStroops = minResourceFee + parseInt(BASE_FEE, 10);
if (settlementFeeStroops > this.maxTransactionFeeStroops) { … }(node_modules/@x402/stellar/dist/cjs/exact/facilitator/index.js.)
So the fee the facilitator actually sponsors is minResourceFee + BASE_FEE, computed from simulation. The envelope's fee field never reaches a ledger — it is a free-text number the caller chooses.
The consequence is that the control can be evaded by exactly the party it is meant to bound. Declare fee: 100 and the daily ledger books 100 stroops, while the facilitator sponsors up to FACILITATOR_FEE_STROOPS_MAINNET (50000). Against the 10,000,000-stroop mainnet ceiling that is a 500x under-count: the ceiling would report ~1% of actual exposure and never trip. A spend ceiling that reads a caller-controlled field is the kind of control that is worse for being believed — which is the argument you yourself made in the comment on point 2.
assertFeeWithinCap has the same problem but it is harmless: the scheme already enforces maxTransactionFeeStroops against the real figure in verify(), so your check is just comparing a discarded number against the same ceiling. Worth a comment saying so, or dropping it.
Two ways out, either is fine by me:
- Reserve the worst case. Book
getNetworkConfig(network).facilitator.feeStroops— the per-settlement ceiling the scheme itself enforces — before settling, then reconcile downward from the settled transaction'sfeeCharged(viarpc.getTransaction(hash)) on success. Conservative, never under-counts, and the reconciliation is best-effort. - Simulate in the guard and use
minResourceFee + BASE_FEE, mirroring whatverify()does. Accurate, but it costs an extra simulation per settle and can drift from what the scheme computes a moment later.
Blocking — the merge result is red
Main has gained an .env.example drift guard since you branched (you are 21 commits behind). Merged locally onto current main:
FAIL src/__tests__/envDrift.test.ts > .env.example drift guard
AssertionError: Found 1 environment variable(s) read in source but missing from .env.example and not in allow-list:
- FACILITATOR_DAILY_SPEND_STROOPS (read at src\config.ts:229)
Test Files 1 failed | 55 passed | 1 skipped (57)
Tests 1 failed | 552 passed | 1 skipped (554)
.env.example documents FACILITATOR_DAILY_SPEND_STROOPS_TESTNET and _MAINNET but not the un-suffixed fallback your config reads second. One line. Everything else in the merge is clean — no conflicts, and the other 552 tests pass.
The three I raised last time, still open
Not re-litigating — I called them non-blocking then and two of them still are. But one has got worse because of what this PR adds.
Fail-closed became fail-permanent, and now on a routine path. The attempt row is created before reserveDailySpend, so the daily-cap refusal runs finalise(attemptId, 'failed', response) — your own test asserts it:
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ state: 'failed' }) }),
)and the idempotency check then replays that stored decline forever:
if (existing?.state === 'settled' || existing?.state === 'failed') {
return (existing.response as unknown as SettleResponseShape) ?? …
}When this was only reachable on store_unavailable it was an edge case. Hitting the daily ceiling is a normal, self-healing condition that clears at the next UTC midnight — but the payer's payload is bricked permanently, and the replay comes back 200 with the success: false body rather than the 403 the first attempt returned. Either do not create the attempt row until after the reservation succeeds, or do not finalise a guard decline as a terminal failed state.
Reservation leak on expire. Unchanged: if store.expire throws after incrby succeeded, the catch returns store_unavailable without rolling back the increment, and the key now has no TTL — so that leaked reservation never expires and the ceiling drifts down permanently.
.env.example allow-list wording. Still describes it as an allow-list of origins without saying that Origin and x-facilitator-caller are both caller-supplied and trivially spoofed by any non-browser client. One sentence saying it is a misconfiguration guard, not access control, would stop someone deploying it as the latter.
Small
The payTo fixture is still not a valid address — StrKey.isValidEd25519PublicKey returns false on it. Inert, since it never reaches Address.fromString, but we have had five fabricated addresses land in a week and I would rather not have one more sitting in a test file. GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 is already used elsewhere in the suite and does validate. (The asset contract id does validate — that one is fine.)
Where this leaves it
The structure is right and I would keep all of it: the guards module, the fail-closed posture, the per-network keys, the reserve-then-rollback ordering, and the test file. What is left is pointing the ledger at the fee that is actually spent, one line in .env.example, and deciding whether a guard decline should be a terminal attempt state. Nothing here is a rewrite.
The ceiling counted `tx.fee` from the caller-supplied envelope, which ExactStellarScheme.settle() discards when it rebuilds the transaction on the facilitator's own account. A caller declaring `fee: 100` was booked for 100 while the facilitator sponsored up to FACILITATOR_FEE_STROOPS (50000) — a 500x under-count that let the control be evaded by exactly the party it bounds. - the /settle route reserves getNetworkConfig(network).facilitator.feeStroops, the most the scheme can sponsor for one settlement, before submitting - reconcileDailySpend reads feeCharged from the settled transaction and releases the unused headroom; if the ledger is unreadable the full reservation stands, so the error is never in the unsafe direction - a failed `expire` now rolls the increment back instead of leaving a reservation with no TTL that would drift the ceiling down permanently - a guard decline withdraws the pre-submission attempt row rather than finalising it as a terminal `failed`, so a routine daily-cap refusal clears with the ceiling instead of bricking the payer's payload forever - .env.example documents the un-suffixed FACILITATOR_DAILY_SPEND_STROOPS fallback the config reads (main's drift guard failed on it) and states that the allow-list is a misconfiguration guard, not access control - tests: a valid payTo fixture, the worst-case reservation and its reconciliation, and the retryable daily-cap path
|
@Miracle656 Both blockers are fixed, plus the three remaining items from the first review. Head is Blocking 1 — the daily ledger now books the fee that is actually sponsored
Blocking 2 — the
|
|
@Miracle656 Re-verified at head
Verification on Ready for re-review. |
Overview
This PR hardens the x402 facilitator
POST /settlepath against sponsored-fee drain: a per-settlement fee ceiling, a rolling daily spend ceiling per network (Redis, fail-closed), an optional caller allow-list, and a tighter per-route rate limit — on top of the existing settle idempotency from #126/#149.Related Issue
Closes #147
Changes
Spend ceiling
src/x402/settleGuards.ts— fee extraction from the payment envelope, per-settlement cap check, atomic Redis daily reservation (INCRBY+ rollback on overrun), fail-closed on store outage.src/config.ts/.env.example—FACILITATOR_DAILY_SPEND_STROOPS_{TESTNET,MAINNET}with tighter mainnet defaults; existingFACILITATOR_FEE_STROOPS_*is the per-settlement cap.Caller allow-list
FACILITATOR_ALLOWED_ORIGINS(Origin orx-facilitator-caller). Empty keeps today's open demo behaviour.Settle path
src/routes/facilitator.ts— apply allow-list + fee cap before any submission; reserve daily spend only for new attempts (replays do not double-count); refusals return SettleResponse witherrorReason: facilitator_declined(403);/settlerate limit default 20/min (FACILITATOR_SETTLE_RATE_MAX).Tests
src/__tests__/facilitatorHarden.test.ts— fee cap, daily cap, per-network keys, allow-list open/closed, idempotent replay without second spend, rate-limit config.src/__tests__/facilitatorSettle.test.ts— mock Redis spend ledger so existing settle/idempotency coverage still runs.Verification Results
/settlehas its own tighter rate limitfacilitator_declinedNotes