fix: prevent double-credit race condition with atomic conditional UPDATE#43
Open
memosr wants to merge 1 commit into
Open
fix: prevent double-credit race condition with atomic conditional UPDATE#43memosr wants to merge 1 commit into
memosr wants to merge 1 commit into
Conversation
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.
Problem
Two code paths can credit the same transaction, creating a race condition that allows double-crediting:
app/api/transactions/[id]/route.ts:235— guards withstatus !== 'pending'at line 209app/api/circle/webhook/route.ts:152— guards withwasAlreadyProcessedat line 148Both paths follow the same vulnerable pattern: read status → check guard → write update + increment credits. The check-then-write is not atomic.
The race:
Time PATCH handler Circle webhook
────────────────────────────────────────────────────────────
t=0 SELECT status → 'pending'
t=1 SELECT status → 'pending'
t=2 Check passes ✓ Check passes ✓
t=3 increment_credits()
t=4 increment_credits() ← DUPLICATE
t=5 UPDATE status = 'complete'
t=6 UPDATE status = 'complete'
Fix
Replace the "check then update" pattern with an atomic conditional UPDATE that only succeeds if the status is still
'pending':This pattern ensures that even if both paths read
'pending'simultaneously, only one UPDATE actually flips the status — the other getsnullback and skips the credit increment.Changes
app/api/transactions/[id]/route.ts(PATCH handler)increment_creditscall after the atomic UPDATE.eq("status", "pending")and uses.maybeSingle()app/api/circle/webhook/route.ts(updateTransactionStatus)pending → confirmed/complete): atomic UPDATE withWHERE status='pending', credit only if row returnedconfirmed → complete,any → failed): plain UPDATE — no double-spend riskImpact
The existing
.single()calls in unrelated paths (GET handler ownership check, admin transaction updates) are untouched since they're not part of the credit flow.