What
The processRewardClaim function (used by the retry processor) reads a quiz submission without a row-level lock, checks rewardClaimed, invokes the Stellar contract (slow external call), then sets rewardClaimed = true. Two concurrent retry ticks or a retry + direct claim can both read rewardClaimed = false and both succeed, crediting the user twice.
Why
This is a direct financial vulnerability. A user could receive double (or more) token rewards for a single quiz submission. Under load or during Stellar network delays, the window for concurrent execution widens.
Scope
In scope:
- Add
FOR UPDATE row lock inside a transaction in processRewardClaim, identical to the pattern in claimReward (lines 161-171)
- Re-check
rewardClaimed after acquiring the lock before proceeding
- Alternatively, wrap the call in
withLock (the distributed lock utility already used by claimReward)
Out of scope:
- Changes to the retry queue logic itself
- Changes to the Stellar contract interaction
- Frontend changes
Acceptance Criteria
Technical Context
Files to modify:
src/modules/rewards/reward.service.ts — processRewardClaim function (lines 68-146)
Pattern to follow:
The claimReward method (lines 155-171) already uses the correct pattern:
const [submission] = await tx
.select()
.from(quizSubmissions)
.where(eq(quizSubmissions.id, submissionId))
.for("update"); // <-- row lock
The retry processor at src/server.ts:44 calls processRewardClaim directly without any locking wrapper.
Related:
src/services/retry-queue.ts — retry processor that calls processRewardClaim
src/utils/lock.ts — withLock distributed lock utility
What
The
processRewardClaimfunction (used by the retry processor) reads a quiz submission without a row-level lock, checksrewardClaimed, invokes the Stellar contract (slow external call), then setsrewardClaimed = true. Two concurrent retry ticks or a retry + direct claim can both readrewardClaimed = falseand both succeed, crediting the user twice.Why
This is a direct financial vulnerability. A user could receive double (or more) token rewards for a single quiz submission. Under load or during Stellar network delays, the window for concurrent execution widens.
Scope
In scope:
FOR UPDATErow lock inside a transaction inprocessRewardClaim, identical to the pattern inclaimReward(lines 161-171)rewardClaimedafter acquiring the lock before proceedingwithLock(the distributed lock utility already used byclaimReward)Out of scope:
Acceptance Criteria
processRewardClaimacquires a row-level lock (FOR UPDATE) before checkingrewardClaimedrewardClaimedcheck happens INSIDE the lock, after acquisitionprocessRewardClaimcalls for the same submission only credit onceTechnical Context
Files to modify:
src/modules/rewards/reward.service.ts—processRewardClaimfunction (lines 68-146)Pattern to follow:
The
claimRewardmethod (lines 155-171) already uses the correct pattern:The retry processor at
src/server.ts:44callsprocessRewardClaimdirectly without any locking wrapper.Related:
src/services/retry-queue.ts— retry processor that callsprocessRewardClaimsrc/utils/lock.ts—withLockdistributed lock utility