fix(economy): close SBC dup-card exploit + non-atomic economy races

Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md:

- Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and
  bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved
  the same card N times, passed validation, and granted the reward while only
  one card was consumed -> any SBC satisfiable with one duplicated card = free
  reward. Now rejected with BadRequest. Regression test added.
- Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement
  compare-and-swap (no transaction plumbing): club::spend_coins conditional
  debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts;
  pack::open_pack claims the pack before minting; market::buy_listing claims
  the listing before charging and releases on debit failure; market::sell_card
  guards the DELETE with owner + rows_affected; checkin::claim uses a
  conditional INSERT ... WHERE NOT EXISTS (today) before paying out.
- Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError;
  checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic).
- Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill;
  submit_sbc binds it so the MY CLUB milestone query stops silently reading 0.

Core suite 179 green + clippy clean.
This commit is contained in:
funman300
2026-08-17 16:00:48 +00:00
parent fbb54eac95
commit 68d10658c7
8 changed files with 190 additions and 35 deletions
+12 -2
View File
@@ -71,7 +71,17 @@ pub async fn open_pack(
.await?
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
if pack.opened {
// Atomically claim the pack before minting any cards: only one concurrent opener
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
let claimed = sqlx::query(
"UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0",
)
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::BadRequest("pack already opened".into()));
}
@@ -128,7 +138,7 @@ pub async fn open_pack(
.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
.bind(&card_ids_json)
.bind(&now)
.bind(pack_id)