Conversation
Concurrent POSTs to /billing/confirm_payment with the same payment_intent_id
all observed metadata.credited != "true" and each created a distinct credit
ledger entry, multiplying the credit by the racing concurrency.
Two layers of defence:
- Per-PaymentIntent tokio Mutex via a moka cache serializes racers locally so
the second to enter sees metadata.credited == "true" and bails.
- Both Stripe POSTs now use deterministic Idempotency-Keys
(credit-mark:{pi}, credit-tx:{pi}). Distinct keys per endpoint avoid
Stripe's same-key/different-fingerprint error; the 24h window collapses
duplicates server-side even if the local lock is bypassed (eviction,
multi-process).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens Stripe top-up confirmation in lit-api-server by preventing duplicate credits when multiple /billing/confirm_payment requests race on the same PaymentIntent.
Changes:
- Added a per-PaymentIntent in-process mutex cache to serialize concurrent
confirm_payment_and_creditcalls. - Switched the PaymentIntent metadata update and Stripe balance-transaction creation to deterministic idempotent POSTs.
- Updated inline documentation to describe the new replay-protection flow.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+588
to
+594
| let lock = state | ||
| .confirm_payment_locks | ||
| .get_with(payment_intent_id.to_string(), async { | ||
| Arc::new(tokio::sync::Mutex::new(())) | ||
| }) | ||
| .await; | ||
| let _guard = lock.lock().await; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CPL-290 —
confirm_payment_and_creditperformed an unguarded check-then-act onmetadata.creditedand posted tocustomers/{cid}/balance_transactionswith noIdempotency-Key. Concurrent POSTs to/billing/confirm_paymentwith the samepayment_intent_idall observedmetadata.credited != "true"and each created a distinct credit ledger entry, multiplying the credit by the racing concurrency. For a $100 PI at N=10 racers, an attacker netted $900 of free credit per fresh PI.Two layers of defence, defense-in-depth:
Per-PaymentIntent
tokio::sync::Mutex— stored in a moka cache (Cache<String, Arc<Mutex<()>>>, 120stime_to_idle) onStripeState, acquired at function entry. Concurrent racers serialize locally; the second to enter the critical section reads the freshly-setmetadata.credited == "true"from the GET and bails with "already credited."Deterministic
Idempotency-Keys on both Stripe POSTs:credit-mark:{payment_intent_id}for the PaymentIntent metadata updatecredit-tx:{payment_intent_id}for thecustomers/{cid}/balance_transactionscreateDistinct keys per endpoint avoid Stripe's "same key, different request fingerprint" error. The 24h idempotency window collapses duplicates server-side even if the local mutex is bypassed (cache eviction, multi-process deploys, restart mid-request).
The
stripe_post_with_idempotencyhelper already existed atstripe.rs:176(used by the per-charge ledger path) — this PR just wiresconfirm_payment_and_credit's two POSTs through it.Pre-Landing Review
Self-review focused on the new locking + idempotency layer. No issues found.
time_to_idle, well above the typical confirm-payment latency. Even on eviction, idempotency keys are the authoritative defence — Stripe collapses duplicates regardless of local lock state.get_withvstry_get_with: Usedget_withfor the lock cache (returnsArc<Mutex>, no fallible result to cache). The mutex is released as soon as the function exits via the_guardRAII binding.credit-mark:vscredit-tx:) — safe because Stripe scopes idempotency keys per (account, key) and stores the request fingerprint. Reusing one key across two different paths would error on the second call.get_customer_by_walletis called while holding the lock. The customer cache is in-process and bounded; under contention the second racer hits a cache hit. No deadlock risk (no nested same-key locks).Test Coverage
No new automated coverage. The existing test pattern in
stripe.rsis unit tests for pure helpers (parse_stripe_response,cents_to_display,cache_key,should_update_balance_cache,unix_to_utc_date,aggregate_report_rows) — there is no HTTP mock infrastructure forstripe_post/stripe_get, so neitherconfirm_payment_and_creditnorchargehave direct unit tests today. Adding one for this fix would require introducing a mock layer beyond the scope of a CRIT race-condition patch.The fix is amenable to manual verification via the exploit POC in the Linear ticket against a test PaymentIntent — concurrent N×curl will now produce one ledger entry instead of N.
Verification
cargo build— cleancargo test --lib(lit-api-server) — 225 passed, 0 failed (23 stripe tests included)cargo clippy --lib --no-deps— clean, no warningscargo fmt --all -- --check— cleanNo rocket route changes; k6 client regen not required.
TODOS
No TODOS.md items completed in this PR.
Test plan
cargo buildcleancargo test --lib— 225/225 passingcargo clippy --lib --no-deps— cleancargo fmt --all -- --check— cleanfor i in 1..5; do curl -X POST .../billing/confirm_payment -d '{"payment_intent_id":"pi_xxx"}' & done; wait) on a test PI and verify exactly one credit ledger entry is created in the Stripe dashboard (was N, should be 1).metadata.creditedflag flips to"true".🤖 Generated with Claude Code