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
+26 -10
View File
@@ -44,7 +44,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
let last_day = &last_at[..10]; // YYYY-MM-DD
let available = last_day != today.as_str();
let next_streak = compute_next_streak(last_streak, &last_at);
let idx = ((next_streak - 1) % 7) as usize;
let idx = (next_streak - 1).rem_euclid(7) as usize;
Ok(CheckinStatus {
available,
streak_day: if available { next_streak } else { last_streak },
@@ -83,19 +83,17 @@ pub async fn claim(
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
let idx = ((last_streak - 1) % 7) as usize;
let idx = (last_streak - 1).rem_euclid(7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
club::add_coins(pool, club_id, coins).await?;
if let Some(def) = pack_def {
let _ = pack::grant_pack(pool, club_id, def).await;
}
// Atomically claim today's check-in: the INSERT lands only if no row exists for
// today, so two concurrent claims cannot both pay out (was a check-then-act race).
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
let inserted = sqlx::query(
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
SELECT ?, ?, ?, ?, ?, ?, ? \
WHERE NOT EXISTS (SELECT 1 FROM daily_checkins WHERE profile_id = ? AND substr(checked_in_at, 1, 10) = ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(profile_id)
@@ -104,8 +102,26 @@ pub async fn claim(
.bind(coins)
.bind(pack_def)
.bind(&now)
.bind(profile_id)
.bind(&today)
.execute(pool)
.await?;
.await?
.rows_affected();
if inserted == 0 {
// A concurrent claim already recorded today's check-in — do not pay out again.
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
club::add_coins(pool, club_id, coins).await?;
if let Some(def) = pack_def {
let _ = pack::grant_pack(pool, club_id, def).await;
}
Ok(CheckinResult {
coins_awarded: coins,