Skip to content

Fix/env validation jwt network platform port - #638

Open
Lanhubs wants to merge 3 commits into
Savitura:mainfrom
Lanhubs:fix/env-validation-jwt-network-platform-port
Open

Fix/env validation jwt network platform port#638
Lanhubs wants to merge 3 commits into
Savitura:mainfrom
Lanhubs:fix/env-validation-jwt-network-platform-port

Conversation

@Lanhubs

@Lanhubs Lanhubs commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

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.

Changes

backend/src/services/rewardTierService.js

  • reserveTierSlot(client, { tierId, campaignId }) — Atomically increments claimed_count via UPDATE ... WHERE (tier_limit IS NULL OR claimed_count < tier_limit) RETURNING. Returns the tier row on success, or null if sold out.
  • assignTierToContribution(client, { ..., tierId }) — Extended to accept an optional tierId. When provided, only creates the contribution_rewards join row (idempotent via ON CONFLICT DO NOTHING) without double-incrementing claimed_count, since the route already reserved the slot.

backend/src/routes/contributions.js

  • Custodial POST / route now extracts optional tier_id from req.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 tierId through to submitCustodialContribution so it flows into metadata

backend/src/services/contributionService.js

  • Accepts new tierId parameter and includes it in stellar_transactions.metadata as tier_id

backend/src/services/ledgerMonitor.js

  • Reads tier_id from stellar_transactions.metadata and passes it to assignTierToContribution, ensuring pre-reserved tiers are honoured without double-counting claimed_count

backend/src/middleware/validation.js

  • Added optional tier_id validation (UUID format) to contributionValidation

How It Works End-to-End

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

Acceptance Criteria

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

Testing

  • Unit test reserveTierSlot() — success path
  • Unit test reserveTierSlot() — sold-out path returns null
  • Unit test assignTierToContribution() with explicit tierId — no double-increment
  • Integration: contribute with valid tier_id, verify claimed_count incremented
  • Integration: contribute with sold-out tier_id, verify 409 response
  • Integration: concurrent contributions for last slot, verify exactly one 202 and one 409

Lanhubs added 3 commits July 29, 2026 12:47
…FORM_SECRET_KEY, PORT

config/env.js validated that JWT_SECRET is present but did not enforce a
minimum length or warn about weak secrets. A short or weak secret could
allow token forgery.

- Validate JWT_SECRET minimum length of 32 characters ( emits a warning
  for short secrets rather than blocking startup, to preserve dev/test
  ergonomics)
- Validate STELLAR_NETWORK is one of testnet or mainnet
- Validate PLATFORM_SECRET_KEY starts with S and is 56 characters
- Validate PORT is a number if set
- Add env.test.js covering each rule
…nv.example

Update Playwright config and unit-test JWT_SECRET stubs to use a value of
at least 32 characters so they pass the new env validation. Document the
JWT_SECRET, STELLAR_NETWORK, PLATFORM_SECRET_KEY, and PORT requirements
in the example env files. No production code or behavior changes.
Pin husky to its current declared version so the npm ci install in CI
can resolve its lifecycle script (which invokes the husky binary).

Without this, npm ci fails with `sh: 1: husky: not found` because the
binary referenced by an existing lifecycle script is not present in the
lockfile.
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.

bug: Reward tier remaining count not decremented when a tier-backed contribution is made

1 participant