Skip to content

fix: prevent double-credit vulnerability in reward claim retry path#70

Merged
DeFiVC merged 3 commits into
ChainLearnOfficial:mainfrom
PrimeFactor-Dev:fix/double-credit-vulnerability
Jul 22, 2026
Merged

fix: prevent double-credit vulnerability in reward claim retry path#70
DeFiVC merged 3 commits into
ChainLearnOfficial:mainfrom
PrimeFactor-Dev:fix/double-credit-vulnerability

Conversation

@PrimeFactor-Dev

Copy link
Copy Markdown
Contributor

Closes #66

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional or behavioral changes)
  • Performance improvement
  • Documentation update
  • Build / CI configuration change
  • Dependency update
  • Other:

Summary

Fixed a double-credit vulnerability where concurrent calls to processRewardClaim could both read rewardClaimed = false and credit the user twice. Added distributed locking (withLock) and row-level locking (FOR UPDATE inside a transaction) to serialize concurrent retry claims for the same submission.

Motivation / Context

Fixes #66

The processRewardClaim function read quiz submissions without a row-level lock, checked rewardClaimed, invoked the Stellar contract (slow external call), then set rewardClaimed = true. Two concurrent retry ticks or a retry + direct claim could both read rewardClaimed = false and both succeed, crediting the user twice. This is a direct financial vulnerability.

Detailed Changes

src/modules/rewards/reward.service.tsprocessRewardClaim function:

  • Wrapped the entire function body in withLock(\reward:${submissionId}`, ...)` for distributed locking
  • Moved all database reads/writes inside db.transaction(async (tx) => { ... })
  • Added .for("update") row-level lock on the submission query
  • Moved the rewardClaimed check inside the lock, after acquisition
  • Moved cache invalidation outside the lock (after commit) to minimize lock hold time

tests/unit/services/process-reward-claim.test.ts:

  • Added withLock mock so tests run without Redis
  • Updated test helpers to mock db.transaction with tx.select().for("update") chain
  • All 6 existing test cases pass with the new implementation

Current Behavior vs. New Behavior

Before: Concurrent processRewardClaim calls could both succeed for the same submission, crediting the user multiple times.
After: Concurrent calls for the same submissionId are serialized via distributed lock + row-level lock — only one succeeds.

Testing

  • npm run typecheck — passes (0 errors)
  • npm run lint — passes (0 errors, 73 pre-existing warnings)
  • npm test — 127 passed, 2 failed (pre-existing credential minting test failures unrelated to this change)
  • All 6 process-reward-claim.test.ts tests pass
  • The 2 remaining failures are pre-existing on main (concurrent-safety.test.ts credential minting tests)

Breaking Changes

No.

Risks and Rollback

Risks:

  • The withLock call requires Redis. If Redis is unavailable, withLock will fail. However, processRewardClaim is only called by the retry processor which already depends on Redis (for the queue).
  • Lock hold time includes the Stellar contract invocation (~seconds). Under extreme load, this could cause lock contention. The existing claimReward method has the same pattern and has been working in production.

Rollback: Revert the commit to restore the original processRewardClaim implementation.

Checklist

Self-Review

  • I have read the entire diff line by line as if a stranger wrote it
  • No debug code remains (console.log, print, debugger, commented-out blocks)
  • No hardcoded secrets, tokens, API keys, or internal URLs
  • Naming is consistent with the existing codebase
  • Error handling is present and produces meaningful messages
  • Edge cases are addressed (null/undefined, empty collections, boundary values)
  • No unused imports, dead code, or unnecessary dependencies

Testing

  • All existing tests pass locally
  • New tests added for new logic (functions, methods, branches)
  • Edge cases and failure paths are tested, not just the happy path
  • Manual testing steps documented above (if applicable)

CI / Pipeline

  • All CI checks are passing (build, lint, test, type-check)
  • No new compiler warnings or linting errors introduced

Documentation

  • README updated if setup, usage, or installation changed
  • API documentation updated for any public interface changes
  • Inline comments added for non-obvious logic (explain "why", not "what")
  • Configuration / env var documentation updated (if applicable)

Security

  • User input is validated and sanitized at trust boundaries
  • No SQL injection, XSS, or injection vulnerabilities introduced
  • Authentication / authorization checks are in place for new endpoints
  • Dependencies have no known critical vulnerabilities

Performance

  • No N+1 database query patterns introduced
  • New queries use appropriate indexes
  • No memory leaks (event listeners cleaned up, connections closed)
  • Large data sets are paginated or streamed, not loaded entirely into memory

Reviewer Notes

  • The pattern used (withLock + db.transaction + .for("update")) matches the existing claimReward method — this is the established pattern in the codebase.
  • Cache invalidation was moved outside the lock to avoid holding the lock during cache operations.

Additional References

  • Pattern source: claimReward method at src/modules/rewards/reward.service.ts:155-309
  • Lock utility: src/utils/lock.ts
  • Retry processor: src/services/retry-queue.ts

…cessRewardClaim

Wrap processRewardClaim in withLock (distributed lock) and db.transaction
with FOR UPDATE row lock, matching the pattern already used by claimReward.
This prevents concurrent retry calls from both reading rewardClaimed=false
and crediting the user twice.

Concurrent calls for the same submissionId are now serialized — only one
succeeds. The retry processor continues to function for legitimate retries.
…wrapping

Add withLock mock to tests so they run without a Redis instance.
Update test helpers to mock db.transaction with tx.select().for(update)
chain, reflecting the new lock-based implementation.

@DeFiVC DeFiVC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Overall: Approve. This is a well-written fix for a critical financial vulnerability. The implementation correctly addresses the double-credit issue in processRewardClaim by following the same pattern already established in claimReward.

Checklist

  • ✅ All CI checks green (Lint/Typecheck pass, Tests pass)
  • Closes #66 in PR description
  • ✅ PR matches issue requirements — addresses double-credit vulnerability
  • ✅ No AI co-authorship markers in commits
  • ✅ No scope creep or unrelated changes
  • ✅ PR description is detailed with motivation, changes, testing, and risks
  • ✅ 2 commits show iterative development (fix + test update)
  • ✅ Follows existing codebase patterns (withLock + db.transaction + .for("update"))

Code Review

The fix is clean and minimal:

  • src/modules/rewards/reward.service.ts: Wraps processRewardClaim in withLock and moves all DB operations inside a transaction with .for("update") row lock
  • Cache invalidation correctly moved outside the lock to minimize lock hold time
  • tests/unit/services/process-reward-claim.test.ts: Updated to mock withLock and transaction helpers properly

Verdict

Approved — safe to merge. The fix follows established patterns and addresses a critical vulnerability correctly.

@DeFiVC
DeFiVC merged commit f6be7a8 into ChainLearnOfficial:main Jul 22, 2026
2 checks passed
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.

Double-credit vulnerability in reward claim retry path

2 participants