fix(contributions): atomically decrement reward tier claimed_count on… - #626
Merged
dotunv merged 2 commits intoJul 28, 2026
Merged
Conversation
… contribution - Add reserveTierSlot() to rewardTierService.js: atomically increments claimed_count inside the contribution route transaction, returns null if the tier is sold out (HTTP 409). - Add optional tierId param to assignTierToContribution(): when set, only creates the contribution_rewards join row without bumping claimed_count again. - Custodial POST / route accepts optional tier_id and reserves the slot atomically via reserveTierSlot(). Rejects with 409 if sold out. - Stored tier_id in stellar_transactions.metadata for ledger monitor. - Ledger monitor passes reserved tier_id from metadata to assignTierToContribution. - Validation middleware accepts optional tier_id field.
|
@innocentharuna Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
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.
Description
Closes #397
Reward tier
claimed_countis now atomically decremented when a tier-backed contribution is made, preventing overselling of limited-edition reward tiers.The Problem
The
claimed_count(and hence computedremaining) onreward_tierswas only incremented asynchronously in the ledger monitor when Horizon detected the on-chain payment. The contribution route itself never decremented the count, so:The Fix
A new
reserveTierSlot()function inrewardTierService.jsatomically incrementsclaimed_countinside the contribution route's database transaction — before the Stellar transaction is even submitted. If the tier is already at capacity, the contribution is rejected with HTTP 409 Sold out and the entire transaction is rolled back.Changes
backend/src/services/rewardTierService.jsreserveTierSlot(client, { tierId, campaignId })— Atomically incrementsclaimed_countviaUPDATE ... WHERE (tier_limit IS NULL OR claimed_count < tier_limit) RETURNING. Returns the tier row on success, ornullif sold out.assignTierToContribution(client, { ..., tierId })— Extended to accept an optionaltierId. When provided, only creates thecontribution_rewardsjoin row (idempotent viaON CONFLICT DO NOTHING) without double-incrementingclaimed_count, since the route already reserved the slot.backend/src/routes/contributions.jsPOST /route now extracts optionaltier_idfromreq.bodySELECT)reserveTierSlot()inside the existing transaction, before submitting to Stellar{ error: "This reward tier is sold out", tier_id }if the tier is at capacitytierIdthrough tosubmitCustodialContributionso it flows into metadatabackend/src/services/contributionService.jstierIdparameter and includes it instellar_transactions.metadataastier_idbackend/src/services/ledgerMonitor.jstier_idfromstellar_transactions.metadataand passes it toassignTierToContribution, ensuring pre-reserved tiers are honoured without double-countingclaimed_countbackend/src/middleware/validation.jstier_idvalidation (UUID format) tocontributionValidationHow It Works End-to-End
Acceptance Criteria
claimed_countdecrements atomically with each contributionUPDATE ... SET claimed_count = claimed_count + 1inside route transactionreserveTierSlot()returns null →res.status(409)UPDATE ... WHERE claimed_count < tier_limitwith PostgreSQL row-level locking inside transactionclaimed_countis updated atomically in the route, so the nextlistTiersWithAvailability()query reflects the correctremainingTesting
reserveTierSlot()— success pathreserveTierSlot()— sold-out path returns nullassignTierToContribution()with explicittierId— no double-incrementtier_id, verifyclaimed_countincrementedtier_id, verify 409 response