Fixes #397 — Reward tier claimed_count is now atomically decremented when a tier-backed contribution is made, preventing overselling of limited-edition reward tiers.
The Problem
The claimed_count (and hence computed remaining) on reward_tiers was only incremented asynchronously in the ledger monitor when Horizon detected the on-chain payment. The contribution route itself never decremented the count, so:
- Sold-out tiers continued showing availability in the UI
- Multiple backers could claim the last slot of a limited tier
- Creators could end up owing more physical rewards than they could fulfil
The Fix
A new reserveTierSlot() function in rewardTierService.js atomically increments claimed_count inside 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.
reserveTierSlot(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.
- Custodial
POST /route now extracts optionaltier_idfromreq.body - Validates the tier belongs to the campaign (pre-flight
SELECT) - Calls
reserveTierSlot()inside the existing transaction, before submitting to Stellar - Returns HTTP 409 with
{ error: "This reward tier is sold out", tier_id }if the tier is at capacity - Passes
tierIdthrough tosubmitCustodialContributionso it flows into metadata
- Accepts new
tierIdparameter and includes it instellar_transactions.metadataastier_id
- Reads
tier_idfromstellar_transactions.metadataand passes it toassignTierToContribution, ensuring pre-reserved tiers are honoured without double-countingclaimed_count
- Added optional
tier_idvalidation (UUID format) tocontributionValidation
User contributes (with tier_id)
│
├─ 1. Route validates tier exists & belongs to campaign (pre-flight)
│
├─ 2. BEGIN transaction
│ ├─ Advisory lock (campaign + user)
│ ├─ Check per-user cap
│ ├─ reserveTierSlot() → UPDATE reward_tiers SET claimed_count++
│ │ WHERE id = tier_id AND claimed_count < tier_limit
│ │ RETURNING id
│ │ └─ If 0 rows → ROLLBACK, respond 409 Sold out 🚫
│ ├─ Submit Stellar transaction
│ ├─ Insert stellar_transactions record (metadata.tier_id set)
│ └─ COMMIT ✅
│
└─ 3. (async) Ledger monitor indexes on-chain payment
├─ Insert contributions row
├─ assignTierToContribution(tierId) → creates contribution_rewards
│ (idempotent via ON CONFLICT DO NOTHING, no double-increment)
└─ Update campaign raised_amount
| Criteria | Status |
|---|---|
Tier claimed_count decrements atomically with each contribution |
✅ UPDATE ... SET claimed_count = claimed_count + 1 inside route transaction |
| Contribution rejected with 409 when tier is sold out | ✅ reserveTierSlot() returns null → res.status(409) |
| Concurrent contributions for the last slot handled correctly (only one succeeds) | ✅ Atomic UPDATE ... WHERE claimed_count < tier_limit with PostgreSQL row-level locking inside transaction |
| Tier sold-out state reflected in UI immediately | ✅ claimed_count is updated atomically in the route, so the next listTiersWithAvailability() query reflects the correct remaining |
- Unit test
reserveTierSlot()— success path - Unit test
reserveTierSlot()— sold-out path returns null - Unit test
assignTierToContribution()with explicittierId— no double-increment - Integration: contribute with valid
tier_id, verifyclaimed_countincremented - Integration: contribute with sold-out
tier_id, verify 409 response - Integration: concurrent contributions for last slot, verify exactly one 202 and one 409