Files
OpenFUT-Core/src/services/checkin.rs
T
funman300 68d10658c7 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.
2026-08-17 16:00:48 +00:00

150 lines
4.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
// Day 7 (index 6) also grants a pack:
const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (17 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct CheckinResult {
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
pub new_streak: i64,
pub already_claimed: bool,
}
pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatus> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
match row {
None => Ok(CheckinStatus {
available: true,
streak_day: 0,
next_reward_coins: STREAK_COINS[0],
next_reward_pack: None,
last_checked_in: None,
}),
Some((last_streak, last_at)) => {
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).rem_euclid(7) as usize;
Ok(CheckinStatus {
available,
streak_day: if available { next_streak } else { last_streak },
next_reward_coins: STREAK_COINS[idx],
next_reward_pack: if idx == 6 { Some(STREAK_7_PACK) } else { None },
last_checked_in: Some(last_at),
})
}
}
}
pub async fn claim(
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
if let Some((_, ref last_at)) = row {
if &last_at[..10] == today.as_str() {
let last_streak = row.as_ref().map(|(s, _)| *s).unwrap_or(1);
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
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 };
// 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();
let inserted = sqlx::query(
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
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)
.bind(club_id)
.bind(last_streak)
.bind(coins)
.bind(pack_def)
.bind(&now)
.bind(profile_id)
.bind(&today)
.execute(pool)
.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,
pack_awarded: pack_def.map(String::from),
new_streak: last_streak,
already_claimed: false,
})
}
fn today_date() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
/// If last check-in was yesterday or today → continue streak; otherwise reset to 1.
fn compute_next_streak(last_streak: i64, last_at: &str) -> i64 {
let last_day = &last_at[..10];
let today = chrono::Utc::now().date_naive();
let yesterday = (today - chrono::Days::new(1)).to_string();
let today_str = today.to_string();
if last_day == yesterday || last_day == today_str {
(last_streak % 7) + 1
} else {
1
}
}