Skip to content

fix(payments): bound L402 verify retries per payment intent - #839

Merged
catomean merged 1 commit into
mainfrom
fix/l402-verify-limit
Aug 29, 2026
Merged

fix(payments): bound L402 verify retries per payment intent#839
catomean merged 1 commit into
mainfrom
fix/l402-verify-limit

Conversation

@catomean

@catomean catomean commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Closes finding 7 from the #563 audit.

The bug

The L402 verify-retry branch sits deliberately outside the per-IP challenge
budget, and that part is right: a payer who has genuinely paid must be able to
retry until settlement is seen, and sharing the challenge budget would lock them
out of their own purchase.

But unbounded is not the same as unbudgeted. Every verify on a non-terminal
intent drives an outbound call — the recipient's NWC or LNURL relay, or
mempool. So one valid token bought unlimited traffic aimed at someone else's
infrastructure, and the per-IP limiter could not see it by design, because the
thing being replayed is a token, not an address.

The fix

rateLimitL402Verify(paymentIntentId) — 60 checks per minute, far more than an
honest client polling a payment needs and far less than a loop. Applied before
the outbound call, so a refused check is one that never leaves the box.

Keyed on the intent, not the preimage. The preimage is caller-supplied, so
keying on it would have allowed exactly the bucket-rotation trick the per-IP
limiter already fell to. The intent id cannot be varied without a valid status
token for a different payment, and it is not a secret (it is in the status
route's own URL), so no hashing is needed.

The budget covers terminal intents too, even though those short-circuit in
refreshPaymentStatus before any rail call — a cheap check is still a check,
and one bound is easier to reason about than two.

Verification

4 new tests: the budget is keyed on the intent; a refused check returns 429
and never reaches verifyL402Payment; verify still does not spend the
challenge budget; and the challenge branch still does not spend the verify one.
22 suites / 174 tests green across the api + payments suites, and a full
non-incremental type-check clean. The box was too loaded to finish one
npm run verify, so it was verified by parts instead: eslint on the changed
files, plus check:sizes / audit:routes / duplication / dead-fields /
client-ip / user-scoped-deletes / one-current-user / rpc-exists /
currency-units all green. CI runs the whole bundle.

Refs #563 finding 7.

The verify-retry branch sits deliberately outside the per-IP challenge
budget, and that part is right: a payer who has genuinely paid must be able
to retry until settlement is seen, and sharing the challenge budget would
lock them out of their own purchase.

But unbounded is not unbudgeted. Every verify on a non-terminal intent
drives an outbound call — the recipient's NWC or LNURL relay, or mempool —
so one valid token bought unlimited traffic aimed at someone else's
infrastructure, and the per-IP limiter could not see it by design, because
the thing being replayed is a token, not an address.

rateLimitL402Verify gives it a budget of its own: 60 checks per minute,
far more than an honest client polling a payment needs and far less than a
loop, applied before the outbound call so a refused check never leaves the
box. Keyed on the INTENT, not the preimage — the preimage is caller-supplied,
so keying on it would have allowed exactly the bucket-rotation the per-IP
limiter already fell to.

The route crossed the 150-line gate on the way, so both branches moved to
lib/api/l402-handlers: they carry different rate budgets and different
failure surfaces, and neither is the route's business. Route is 36 lines.

Refs #563 finding 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
@catomean
catomean merged commit e61fd96 into main Aug 29, 2026
6 checks passed
@catomean
catomean deleted the fix/l402-verify-limit branch August 29, 2026 09:11
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.

1 participant