Files
OpenFUT-Core/src/services/season.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

166 lines
5.2 KiB
Rust

use crate::{
db::Pool,
error::{AppError, AppResult},
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
services::{club, pack},
};
use uuid::Uuid;
/// Get the current season for a profile, creating it if it doesn't exist.
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
if let Some(s) = fetch(pool, profile_id).await? {
return Ok(s);
}
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO seasons (profile_id, division, season_number, season_points, \
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
)
.bind(profile_id)
.bind(&now)
.execute(pool)
.await?;
fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
}
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
let s = sqlx::query_as::<_, Season>(
"SELECT profile_id, division, season_number, season_points, matches_played, \
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
Ok(s)
}
/// Record a match result in the season; end the season if the quota is met.
///
/// Returns the updated season and an optional end-of-season summary.
pub async fn record_match(
pool: &Pool,
club_id: &str,
profile_id: &str,
outcome: &str,
) -> AppResult<(Season, Option<SeasonEndSummary>)> {
let points = match outcome {
"win" => 3,
"draw" => 1,
_ => 0,
};
sqlx::query(
"UPDATE seasons SET \
season_points = season_points + ?, \
matches_played = matches_played + 1, \
wins = wins + CASE WHEN ? = 'win' THEN 1 ELSE 0 END, \
draws = draws + CASE WHEN ? = 'draw' THEN 1 ELSE 0 END, \
losses = losses + CASE WHEN ? = 'loss' THEN 1 ELSE 0 END \
WHERE profile_id = ?",
)
.bind(points)
.bind(outcome)
.bind(outcome)
.bind(outcome)
.bind(profile_id)
.execute(pool)
.await?;
let season = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
if !season.is_complete() {
return Ok((season, None));
}
// Season complete — calculate result and start next
let result = season.end_result();
let old_div = season.division;
let coins = season.season_reward_coins();
let pack_id = season.season_reward_pack();
let new_div = match result {
SeasonResult::Promoted => (old_div - 1).max(1),
SeasonResult::Relegated => (old_div + 1).min(10),
SeasonResult::Maintained => old_div,
};
let new_season = season.season_number + 1;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
matches_played = 0, wins = 0, draws = 0, losses = 0, started_at = ? \
WHERE profile_id = ?",
)
.bind(new_div)
.bind(new_season)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
// Grant rewards
club::add_coins(pool, club_id, coins).await?;
if let Some(pack_def) = pack_id {
pack::grant_pack(pool, club_id, pack_def).await?;
}
// Persist history entry before rolling over
let result_str = match result {
SeasonResult::Promoted => "promoted",
SeasonResult::Maintained => "maintained",
SeasonResult::Relegated => "relegated",
};
let history_id = Uuid::new_v4().to_string();
let _ = sqlx::query(
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&history_id)
.bind(profile_id)
.bind(season.season_number)
.bind(old_div)
.bind(season.season_points)
.bind(season.wins)
.bind(season.draws)
.bind(season.losses)
.bind(result_str)
.bind(new_div)
.bind(coins)
.bind(pack_id)
.bind(&now)
.execute(pool)
.await;
let summary = SeasonEndSummary {
result,
old_division: old_div,
new_division: new_div,
new_season_number: new_season,
coins_awarded: coins,
pack_awarded: pack_id.map(String::from),
};
let updated = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
Ok((updated, Some(summary)))
}
/// Return past seasons for a profile, newest first (max 20).
pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult<Vec<SeasonHistoryEntry>> {
let rows = sqlx::query_as::<_, SeasonHistoryEntry>(
"SELECT id, profile_id, season_number, division, season_points, wins, draws, losses, \
result, new_division, coins_awarded, pack_awarded, ended_at \
FROM season_history WHERE profile_id = ? ORDER BY season_number DESC LIMIT 20",
)
.bind(profile_id)
.fetch_all(pool)
.await?;
Ok(rows)
}