3 Commits

Author SHA1 Message Date
funman300 637a21eac1 fix(economy): quick-selling a squadded card failed with a FOREIGN KEY error
Found while implementing sale settlement, and reproduced before fixing:

  sell_item(pool, "club", "item-x", 250)
    -> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })

squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.

sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).

Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.

Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.

Not deployed — production is mid live-test.
2026-08-18 01:01:13 +00:00
funman300 31ab4a683e feat(economy): atomic market sale settlement — transfer ownership, credit seller, destroy fee
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row
(services/economy.rs::purchase_item), so a sold card existed twice; the seller was
never credited; no fee arithmetic existed anywhere in the project; and no
/economy/* route could even name a counterparty, since all eight resolve one club
from the X-OpenFUT-Game active profile.

Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the
module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than
re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row
-> credit seller gross-fee. The fee is simply never credited anywhere, which is
what destroys it. Exposed as POST /economy/settle-sale, the one economy route that
names clubs explicitly because a sale has two sides; an omitted buyer means a
counterparty OUTSIDE the modelled economy, never a silent fallback to the active
club (which would settle a club against itself).

Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No
INSERT and no DELETE on the two-party path, so duplication is ruled out
structurally, not by an assertion.

Two bugs found by writing the tests rather than by reading the code:

1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed —
   after the first sale the item belonged to B, so the second call read B as the
   seller and chain-sold it B -> C. Core has no listing concept and could not
   notice the replay. The seller is now the caller's EXPECTED owner and every
   ownership statement is predicated on it, which makes the CAS authoritative about
   "already sold" independently of caller-side listing state.

2. Debiting before transferring made a replay fail as "insufficient balance" (the
   buyer had spent the coins on the sale that succeeded), so the ownership guard was
   shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising
   the guard it named. Ownership is now judged first; the test asserts NotFound
   specifically and adds a cheap affordable replay that only ownership can refuse.

Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and
the pool enables foreign_keys, so an Outside sale of a squadded card would fail
outright, and a transfer preserves the row id so a stale lineup row would leave the
PREVIOUS owner fielding a card they no longer own.

Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad
eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race)
+ 6 route-level over HTTP with two parties. Core 194 pass.

Deliberately NOT done: no wire/route output change, no deployment, and nothing yet
decides that a player's listing has sold — the seller-facing sold wire state needs
live client evidence and must not be guessed.
2026-08-18 00:51:20 +00:00
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
12 changed files with 1300 additions and 42 deletions
@@ -0,0 +1,11 @@
-- Issue 1: sbc_submissions was created (0001_initial.sql) without a club_id column,
-- but the MY CLUB milestone query (routes/club.rs get_milestones) counts
-- SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1
-- so SQLite errored on the unknown column and the error was swallowed by
-- `.unwrap_or(0)` -> the `sbcs_completed` milestone always read 0. Add the column
-- and backfill it from the profile's club so historical submissions count.
ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;
UPDATE sbc_submissions
SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id = sbc_submissions.profile_id)
WHERE club_id IS NULL;
+4
View File
@@ -198,6 +198,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
"/economy/purchase-items",
post(routes::economy::post_purchase_items),
)
.route(
"/economy/settle-sale",
post(routes::economy::post_settle_sale),
)
.route(
"/collection/:owned_card_id",
delete(routes::cards::delete_owned_card),
+6 -6
View File
@@ -12,6 +12,7 @@ use crate::{
models::card::OwnedCard,
services::{
club as club_svc,
economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
@@ -184,12 +185,11 @@ pub async fn delete_owned_card(
let coins = quick_sell_coins(card.overall);
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
.bind(&owned_card_id)
.execute(&state.pool)
.await?;
club_svc::add_coins(&state.pool, &club.id, coins).await?;
// Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
// that pair ran on the pool with NO transaction (a failed credit left the card
// destroyed for nothing) and it skipped `squad_players`, whose FK onto
// `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787.
economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?;
Ok(Json(json!({
"quick_sold": owned_card_id,
+57
View File
@@ -161,3 +161,60 @@ pub async fn post_purchase_items(
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
Ok(Json(BalanceResponse { balance }))
}
/// `POST /economy/settle-sale` request.
///
/// This is the ONE economy route that names clubs explicitly, and it has to: a
/// market sale has two sides, and the module's active-profile resolution can only
/// ever describe one. Both are optional and default to the game-scoped active
/// club, so the single-player case stays as terse as every other route:
///
/// * `seller_club_id` omitted -> the active club is the seller (it listed the
/// item), which is the production shape.
/// * `buyer_club_id` omitted -> the counterparty is OUTSIDE the modelled
/// economy: no balance is debited and the item leaves the inventory. It does
/// NOT silently fall back to the active club, because that would settle a sale
/// between a club and itself.
#[derive(Deserialize)]
pub struct SettleSaleRequest {
/// The authoritative owned-item instance changing hands.
pub item_id: String,
/// What the buyer pays. The fee is withheld from this, never added to it.
pub gross: i64,
/// Withheld from the seller and destroyed. The RATE is a per-game policy the
/// caller owns; Core only checks `0 <= fee <= gross`.
pub fee: i64,
#[serde(default)]
pub seller_club_id: Option<String>,
#[serde(default)]
pub buyer_club_id: Option<String>,
}
/// `POST /economy/settle-sale` — atomically debit the buyer, transfer the existing
/// item, credit the seller net of the fee, and destroy the fee.
pub async fn post_settle_sale(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SettleSaleRequest>,
) -> AppResult<Json<economy::SaleReceipt>> {
let seller = match req.seller_club_id {
Some(id) => id,
None => resolve_club(&state, &game).await?,
};
let buyer = match req.buyer_club_id.as_deref() {
Some(id) => economy::SaleBuyer::Club(id),
None => economy::SaleBuyer::Outside,
};
let receipt = economy::settle_sale(
&state.pool,
&req.item_id,
&seller,
buyer,
economy::SaleTerms {
gross: req.gross,
fee: req.fee,
},
)
.await?;
Ok(Json(receipt))
}
+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,
+31 -12
View File
@@ -86,24 +86,43 @@ pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64
}
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.fetch_one(pool)
.await?;
if amount < 0 {
return Err(AppError::BadRequest(format!(
"cannot spend a negative amount: {amount}"
)));
}
if balance < amount {
let now = Utc::now();
// Atomic compare-and-swap: the `coins >= ?` guard makes the debit conditional in a
// single statement, so two concurrent spends can never both pass a stale balance
// check and drive coins negative (the old SELECT-then-UPDATE was a TOCTOU race).
let affected = sqlx::query(
"UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ? AND coins >= ?",
)
.bind(amount)
.bind(now)
.bind(club_id)
.bind(amount)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
// No row updated: the club is missing, or it could not afford the debit.
// Disambiguate so callers keep the NotFound vs BadRequest distinction.
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
return Err(AppError::BadRequest(format!(
"insufficient coins: have {balance}, need {amount}"
)));
}
let now = Utc::now();
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
.bind(amount)
.bind(now)
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.execute(pool)
.fetch_one(pool)
.await?;
Ok(balance - amount)
Ok(new_balance)
}
+700 -1
View File
@@ -183,6 +183,68 @@ async fn remove_item(
Ok(card_id)
}
/// Drop `item_id` out of every lineup that references it.
///
/// REQUIRED before an item changes owner or leaves the inventory, for two
/// independent reasons:
///
/// * `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
/// enables `foreign_keys`, so DELETEing a squadded item fails outright;
/// * on a transfer the row id is preserved, so a stale `squad_players` row
/// would leave the PREVIOUS owner fielding a card they no longer own.
///
/// Every existing reference is invalid the moment ownership moves, so this is
/// scoped by item rather than by club. Returns the number of lineup slots freed.
async fn evict_from_squads(conn: &mut SqliteConnection, item_id: &str) -> AppResult<u64> {
Ok(
sqlx::query("DELETE FROM squad_players WHERE owned_card_id = ?")
.bind(item_id)
.execute(&mut *conn)
.await?
.rows_affected(),
)
}
/// Reassign one existing owned item from `from_club` to `to_club`, returning its
/// definition ref.
///
/// This is a TRANSFER, not a mint: there is no INSERT, so the instance id and its
/// upgrade state survive and the inventory row count is unchanged. The
/// `club_id = from_club` predicate makes the UPDATE an ownership compare-and-swap
/// — if a concurrent settlement moved the item first, `rows_affected` is 0 and
/// this is a [`AppError::Conflict`] rather than a second transfer.
///
/// Callers MUST have run [`evict_from_squads`] first: the row id is preserved, so
/// any surviving lineup reference would belong to the previous owner.
async fn transfer_item(
conn: &mut SqliteConnection,
item_id: &str,
from_club: &str,
to_club: &str,
) -> AppResult<String> {
let card_id = sqlx::query_scalar::<_, String>(
"SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(item_id)
.bind(from_club)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
let affected = sqlx::query("UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ?")
.bind(to_club)
.bind(item_id)
.bind(from_club)
.execute(&mut *conn)
.await?
.rows_affected();
if affected != 1 {
return Err(AppError::Conflict(format!(
"ownership of {item_id} changed during settlement"
)));
}
Ok(card_id)
}
// ---- composed atomic operations ----------------------------------------------
/// Read a club's current currency balance.
@@ -326,11 +388,20 @@ pub async fn redeem_entitlement(
}
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
/// is not owned by the club nothing is credited.
/// is not owned by the club nothing is credited and no lineup is disturbed.
///
/// The item is dropped from any lineup first. `squad_players.owned_card_id` is a FK
/// onto `owned_cards(id)` and the pool enables `foreign_keys`, so without this a
/// quick sell of a squadded card fails with SQLite error 787 instead of selling it
/// — and selling a card that happens to be in your squad is ordinary, not an edge
/// case.
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
// Safe to free slots before the ownership check in `remove_item`: both share
// this transaction, so a wrong-owner sale rolls the eviction back with it.
evict_from_squads(&mut conn, item_id).await?;
remove_item(&mut conn, club_id, item_id).await?;
credit(&mut conn, club_id, price).await
}
@@ -346,6 +417,155 @@ pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<
finish(&mut conn, result).await
}
/// Who acquires the item in a settled sale.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaleBuyer<'a> {
/// A club held by this Core: its balance is debited `gross` and it becomes
/// the item's owner. Coins move BETWEEN modelled balances, so the economy
/// only loses the fee.
Club(&'a str),
/// A counterparty outside the modelled economy (e.g. a synthetic market
/// buyer, which owns no `clubs` row). Nothing is debited and the item leaves
/// the inventory. The seller's proceeds therefore ENTER the economy from
/// outside — the accounting invariant differs from [`SaleBuyer::Club`] and
/// the caller is responsible for wanting that.
Outside,
}
/// Money terms of a sale. `fee` is supplied by the caller, never computed here:
/// the rate is a per-game policy constant and Core is game-neutral.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SaleTerms {
/// What the buyer pays.
pub gross: i64,
/// Withheld from the seller and destroyed. `0 <= fee <= gross`.
pub fee: i64,
}
/// What a settlement did, for the caller to render and for audit.
#[derive(Debug, Clone, Serialize)]
pub struct SaleReceipt {
pub item_id: String,
pub card_id: String,
pub seller_club_id: String,
pub buyer_club_id: Option<String>,
pub gross: i64,
pub fee: i64,
/// `gross - fee`, credited to the seller.
pub proceeds: i64,
pub seller_balance: i64,
/// Post-debit buyer balance; `None` for [`SaleBuyer::Outside`].
pub buyer_balance: Option<i64>,
/// Lineup slots freed because the item changed hands.
pub squad_slots_freed: u64,
}
/// Settle a completed market sale as ONE atomic transaction: debit the buyer,
/// move the EXISTING item, credit the seller their proceeds, destroy the fee.
///
/// `seller_club_id` is the club the CALLER believes owns the item, and every
/// ownership statement is predicated on it. Core deliberately does NOT infer the
/// seller from current ownership: doing so makes a replayed settlement look like a
/// brand-new sale by the item's new owner, and Core has no listing concept with
/// which to notice. Pinning the expected seller turns the ownership UPDATE into a
/// compare-and-swap that is the authority on "this sale has already happened",
/// independent of any caller-side listing state. A caller still cannot credit a
/// club that did not own the item, because the credit only follows a matched CAS.
///
/// Ownership moves by UPDATE — no row is inserted or deleted on the
/// [`SaleBuyer::Club`] path, which is what structurally rules out the duplication
/// a mint-based "buy" causes.
///
/// Fail-closed and all-or-nothing. Rejected without touching any balance:
/// negative `gross`/`fee`, `fee > gross`, an item the named seller does not own
/// (including a replay, where ownership has already moved), a buyer that cannot
/// afford `gross`, or a buyer that is already the seller (not a market path — it
/// would otherwise credit and debit the same club and destroy the fee for nothing).
///
/// Ownership is judged BEFORE affordability, so a replay is reported as "not owned"
/// rather than as the buyer being broke from the sale that already succeeded.
///
/// Coin conservation for [`SaleBuyer::Club`]: `gross` leaves the buyer, `gross -
/// fee` reaches the seller, and the economy shrinks by exactly `fee`.
pub async fn settle_sale(
pool: &Pool,
item_id: &str,
seller_club_id: &str,
buyer: SaleBuyer<'_>,
terms: SaleTerms,
) -> AppResult<SaleReceipt> {
let SaleTerms { gross, fee } = terms;
if gross < 0 {
return Err(AppError::BadRequest(
"sale gross must be non-negative".into(),
));
}
if fee < 0 {
return Err(AppError::BadRequest("sale fee must be non-negative".into()));
}
if fee > gross {
return Err(AppError::BadRequest(format!(
"sale fee {fee} exceeds gross {gross}"
)));
}
if let SaleBuyer::Club(buyer_club) = buyer {
if buyer_club == seller_club_id {
return Err(AppError::Conflict(format!(
"buyer and seller are the same club: {buyer_club}"
)));
}
}
let proceeds = gross - fee;
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
let (card_id, buyer_club_id, buyer_balance, squad_slots_freed) = match buyer {
SaleBuyer::Club(buyer_club) => {
// Ownership is checked FIRST, and the order matters for the REASON a
// rejection carries even though it cannot change the final state
// (everything here shares one transaction, so any error rolls the
// whole thing back either way).
//
// Debiting first made a replayed settlement fail as "insufficient
// balance": after a sale the buyer has already spent the coins, so the
// affordability guard fired before the ownership CAS was ever
// consulted. That is a misleading answer to "why was this refused",
// and a retry test written against it passes without ever exercising
// the replay guard it claims to test.
let freed = evict_from_squads(&mut conn, item_id).await?;
let card_id = transfer_item(&mut conn, item_id, seller_club_id, buyer_club).await?;
let buyer_balance = debit(&mut conn, buyer_club, gross).await?;
(
card_id,
Some(buyer_club.to_string()),
Some(buyer_balance),
freed,
)
}
SaleBuyer::Outside => {
let freed = evict_from_squads(&mut conn, item_id).await?;
let card_id = remove_item(&mut conn, seller_club_id, item_id).await?;
(card_id, None, None, freed)
}
};
let seller_balance = credit(&mut conn, seller_club_id, proceeds).await?;
Ok(SaleReceipt {
item_id: item_id.to_string(),
card_id,
seller_club_id: seller_club_id.to_string(),
buyer_club_id,
gross,
fee,
proceeds,
seller_balance,
buyer_balance,
squad_slots_freed,
})
}
.await;
finish(&mut conn, result).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -409,6 +629,442 @@ mod tests {
.unwrap()
}
/// Two-party market fixture. Deliberately NOT the single-club `fixture()`:
/// settling a sale where the buyer is also the seller is not a market path and
/// would hide every ownership bug this suite exists to catch.
///
/// Seller `club-a` holds 1_000 coins and owns `item-x`; buyer `club-b` holds
/// 20_000 and owns nothing. Profiles are seeded by raw SQL, which sidesteps the
/// one-profile-per-game guard in `services::profile`.
async fn market_fixture() -> Pool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
for (profile, club, coins) in [("prof-a", "club-a", 1_000i64), ("prof-b", "club-b", 20_000)]
{
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club)
.bind(profile)
.bind(club)
.bind(coins)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("club");
}
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind("item-x")
.bind("club-a")
.bind("def-x")
.bind(TS)
.execute(&pool)
.await
.expect("owned card");
pool
}
async fn owner(pool: &Pool, item_id: &str) -> Option<String> {
sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?")
.bind(item_id)
.fetch_optional(pool)
.await
.unwrap()
}
/// Total coins across every modelled balance — the quantity a sale between two
/// clubs must reduce by EXACTLY the fee.
async fn total_coins(pool: &Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
.fetch_one(pool)
.await
.unwrap()
}
/// Put `item-x` in a squad owned by `club_id`, returning nothing. Used to prove
/// a sold card cannot stay in the previous owner's lineup.
async fn squad_up(pool: &Pool, club_id: &str, item_id: &str) {
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, 'S', '4-4-2', ?, ?)")
.bind("squad-1")
.bind(club_id)
.bind(TS)
.bind(TS)
.execute(pool)
.await
.expect("squad");
sqlx::query("INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) VALUES (?, ?, ?, 0)")
.bind("sp-1")
.bind("squad-1")
.bind(item_id)
.execute(pool)
.await
.expect("squad player");
}
async fn squad_slot_count(pool: &Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players")
.fetch_one(pool)
.await
.unwrap()
}
const CANON_GROSS: i64 = 15_000;
const CANON_FEE: i64 = 750;
/// THE canonical settlement: gross 15_000, fee 750, proceeds 14_250.
#[tokio::test]
async fn settle_sale_transfers_ownership_and_splits_coins() {
let pool = market_fixture().await;
assert_eq!(total_coins(&pool).await, 21_000);
let receipt = settle_sale(
&pool,
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
},
)
.await
.expect("settlement");
assert_eq!(
receipt.seller_club_id, "club-a",
"seller derived from owner"
);
assert_eq!(receipt.buyer_club_id.as_deref(), Some("club-b"));
assert_eq!(receipt.card_id, "def-x");
assert_eq!(receipt.gross, 15_000);
assert_eq!(receipt.fee, 750);
assert_eq!(receipt.proceeds, 14_250);
assert_eq!(receipt.buyer_balance, Some(5_000), "20_000 - 15_000");
assert_eq!(receipt.seller_balance, 15_250, "1_000 + 14_250");
assert_eq!(balance(&pool, "club-b").await.unwrap(), 5_000);
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250);
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
// The decisive anti-duplication assertion: ONE authoritative instance,
// the same id as before. A mint-based buy would make this 2.
assert_eq!(item_count(&pool, "item-x").await, 1);
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
.fetch_one(&pool)
.await
.unwrap(),
1,
"no second row anywhere in the inventory"
);
}
/// buyer_debit == seller_credit + fee, and the economy shrinks by exactly the
/// fee. This is the invariant that catches a coin created or destroyed by a
/// rounding or ordering mistake.
#[tokio::test]
async fn sale_conserves_coins_minus_the_fee() {
let pool = market_fixture().await;
let before = total_coins(&pool).await;
let receipt = settle_sale(
&pool,
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
},
)
.await
.unwrap();
let after = total_coins(&pool).await;
assert_eq!(before, 21_000);
assert_eq!(after, 20_250);
assert_eq!(before - after, receipt.fee, "economy shrinks by the fee");
assert_eq!(
receipt.gross,
receipt.proceeds + receipt.fee,
"buyer debit == seller credit + fee"
);
}
/// A sold card must leave the previous owner's lineup. Without eviction the
/// seller keeps fielding a card the buyer owns (and on the `Outside` path the
/// FK makes the delete fail outright).
#[tokio::test]
async fn sale_evicts_the_item_from_the_sellers_squad() {
let pool = market_fixture().await;
squad_up(&pool, "club-a", "item-x").await;
assert_eq!(squad_slot_count(&pool).await, 1);
let receipt = settle_sale(
&pool,
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: 1_000,
fee: 50,
},
)
.await
.unwrap();
assert_eq!(receipt.squad_slots_freed, 1);
assert_eq!(
squad_slot_count(&pool).await,
0,
"stale lineup slot survived"
);
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
}
/// Selling to a counterparty outside the modelled economy: the seller is paid
/// net and the item leaves the inventory. No club is debited, so the seller's
/// proceeds legitimately ENTER the economy.
#[tokio::test]
async fn settle_sale_to_outside_retires_the_item_and_pays_net() {
let pool = market_fixture().await;
squad_up(&pool, "club-a", "item-x").await;
let receipt = settle_sale(
&pool,
"item-x",
"club-a",
SaleBuyer::Outside,
SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
},
)
.await
.unwrap();
assert_eq!(receipt.buyer_club_id, None);
assert_eq!(receipt.buyer_balance, None);
assert_eq!(receipt.seller_balance, 15_250);
assert_eq!(receipt.squad_slots_freed, 1);
assert_eq!(item_count(&pool, "item-x").await, 0, "item was retired");
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "untouched");
// Buyer coins are not modelled, so total coins RISE by the proceeds here.
assert_eq!(total_coins(&pool).await, 21_000 + 14_250);
}
/// Every invalid sale must leave the economy bit-identical.
#[tokio::test]
async fn invalid_sales_change_nothing() {
let terms = SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
};
// (label, item, buyer, terms) -> must fail without mutating anything
let cases: Vec<(&str, &str, &str, SaleBuyer<'_>, SaleTerms)> = vec![
(
"buyer cannot afford",
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: 20_001,
fee: 0,
},
),
(
"buyer is the seller",
"item-x",
"club-a",
SaleBuyer::Club("club-a"),
terms,
),
(
"item does not exist",
"ghost",
"club-a",
SaleBuyer::Club("club-b"),
terms,
),
(
"buyer club does not exist",
"item-x",
"club-a",
SaleBuyer::Club("ghost"),
terms,
),
(
"fee exceeds gross",
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: 100,
fee: 101,
},
),
(
"negative fee",
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms {
gross: 100,
fee: -1,
},
),
(
"negative gross",
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms { gross: -1, fee: 0 },
),
(
"outside sale of a missing item",
"ghost",
"club-a",
SaleBuyer::Outside,
terms,
),
];
for (label, item, seller, buyer, terms) in cases {
let pool = market_fixture().await;
let err = settle_sale(&pool, item, seller, buyer, terms).await;
assert!(err.is_err(), "{label}: expected rejection");
assert_eq!(balance(&pool, "club-a").await.unwrap(), 1_000, "{label}");
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "{label}");
assert_eq!(
owner(&pool, "item-x").await.as_deref(),
Some("club-a"),
"{label}: ownership moved on a rejected sale"
);
assert_eq!(item_count(&pool, "item-x").await, 1, "{label}");
}
}
/// A zero-price sale is legal (a free transfer) and pays no fee.
#[tokio::test]
async fn zero_price_sale_is_a_free_transfer() {
let pool = market_fixture().await;
let receipt = settle_sale(
&pool,
"item-x",
"club-a",
SaleBuyer::Club("club-b"),
SaleTerms { gross: 0, fee: 0 },
)
.await
.unwrap();
assert_eq!(receipt.proceeds, 0);
assert_eq!(total_coins(&pool).await, 21_000, "no coins moved");
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
}
/// Settling the SAME sale twice must not pay the seller twice, and must be
/// refused for the RIGHT reason: the item is no longer the seller's.
///
/// Asserting only "the second call failed" is not enough, and this test used to
/// make exactly that mistake. When the buyer's debit ran first, a replay of the
/// canonical sale was rejected because the buyer had already spent the coins —
/// the ownership CAS was never reached, so the test passed while proving nothing
/// about replay safety. Both a same-price replay and a cheap AFFORDABLE replay
/// are checked, and the error must name ownership in each.
#[tokio::test]
async fn settling_the_same_sale_twice_pays_once() {
let pool = market_fixture().await;
let terms = SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
};
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms)
.await
.expect("first settlement");
for (label, replay) in [
("same price", terms),
// Trivially affordable out of the 5_000 the buyer has left, so ownership
// is the ONLY thing that can refuse it.
("affordable", SaleTerms { gross: 100, fee: 5 }),
] {
let again =
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), replay).await;
assert!(
matches!(again, Err(AppError::NotFound(_))),
"{label} replay must be refused on OWNERSHIP, got {again:?}"
);
}
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
assert_eq!(
balance(&pool, "club-b").await.unwrap(),
5_000,
"debited once"
);
assert_eq!(item_count(&pool, "item-x").await, 1);
assert_eq!(total_coins(&pool).await, 20_250, "fee taken once");
}
/// Two buyers racing the same listing: exactly one wins, and the loser's
/// balance is untouched. This is where a market bug becomes a duplication
/// exploit, so it is asserted on the authoritative state, not on call counts.
#[tokio::test]
async fn two_buyers_racing_one_item_settle_once() {
let pool = market_fixture().await;
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('prof-c','prof-c',?,?)")
.bind(TS).bind(TS).execute(&pool).await.unwrap();
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('club-c','prof-c','club-c',20000,?,?)")
.bind(TS).bind(TS).execute(&pool).await.unwrap();
let terms = SaleTerms {
gross: CANON_GROSS,
fee: CANON_FEE,
};
let (b, c) = tokio::join!(
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms),
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-c"), terms),
);
assert_eq!(
[b.is_ok(), c.is_ok()].iter().filter(|ok| **ok).count(),
1,
"exactly one buyer may win"
);
assert_eq!(
item_count(&pool, "item-x").await,
1,
"no duplicate instance"
);
let winner = owner(&pool, "item-x").await.expect("item still owned");
assert!(winner == "club-b" || winner == "club-c");
let loser = if winner == "club-b" {
"club-c"
} else {
"club-b"
};
assert_eq!(
balance(&pool, loser).await.unwrap(),
20_000,
"the losing buyer must be untouched"
);
assert_eq!(balance(&pool, &winner).await.unwrap(), 5_000);
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
assert_eq!(total_coins(&pool).await, 40_250, "41_000 - 750 fee, once");
}
#[tokio::test]
async fn balance_reads_seeded_value() {
let pool = fixture().await;
@@ -544,6 +1200,49 @@ mod tests {
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
}
/// Quick-selling a card that is IN A SQUAD must work.
///
/// `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
/// enables `foreign_keys`, so deleting a squadded item fails outright. This is
/// the live FIFA 17 quick-sell path (`econ.sell_item` -> `POST
/// /economy/sell-item`), and a player selling a card that is in their lineup is
/// completely ordinary — it is not an edge case.
#[tokio::test]
async fn selling_a_squadded_item_succeeds_and_frees_the_slot() {
let pool = fixture().await;
squad_up(&pool, "club", "item-x").await;
assert_eq!(squad_slot_count(&pool).await, 1);
let new_balance = sell_item(&pool, "club", "item-x", 250)
.await
.expect("quick sell of a squadded card");
assert_eq!(new_balance, 1250);
assert_eq!(item_count(&pool, "item-x").await, 0);
assert_eq!(
squad_slot_count(&pool).await,
0,
"the lineup slot must be freed, not left dangling"
);
}
/// A rejected quick sell must not strip the real owner's lineup. Eviction runs
/// before the ownership check, so this proves the shared transaction actually
/// rolls it back rather than leaving a half-applied squad change.
#[tokio::test]
async fn a_rejected_quick_sell_leaves_the_lineup_intact() {
let pool = fixture().await;
squad_up(&pool, "club", "item-x").await;
assert!(matches!(
sell_item(&pool, "someone-else", "item-x", 250).await,
Err(AppError::NotFound(_))
));
assert_eq!(squad_slot_count(&pool).await, 1, "lineup was disturbed");
assert_eq!(item_count(&pool, "item-x").await, 1);
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
}
#[tokio::test]
async fn grant_reward_credits() {
let pool = fixture().await;
+24 -6
View File
@@ -141,12 +141,23 @@ pub async fn buy_listing(
.await?
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
club::spend_coins(pool, club_id, listing.price).await?;
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
// Atomically claim the listing (flip sold 0->1) before charging, so two concurrent
// buyers cannot both mint the same card. If the debit then fails, release the claim.
let claimed = sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ? AND sold = 0")
.bind(&listing.id)
.execute(pool)
.await?;
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::NotFound("listing not found or already sold".into()));
}
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
.bind(&listing.id)
.execute(pool)
.await;
return Err(e);
}
let owned_id = Uuid::new_v4().to_string();
sqlx::query(
@@ -206,10 +217,17 @@ pub async fn sell_card(
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
// Atomically claim the card: guard the DELETE with the owner + rows_affected so two
// concurrent sells of the same card cannot both credit (double payout).
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(&req.owned_card_id)
.bind(club_id)
.execute(pool)
.await?;
.await?
.rows_affected();
if deleted == 0 {
return Err(AppError::NotFound("owned card not found".into()));
}
let coins = (req.price as f64 * 0.4) as i64;
let new_balance = club::add_coins(pool, club_id, coins).await?;
+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)
+26 -1
View File
@@ -12,6 +12,10 @@ use anyhow::Context;
use std::path::Path;
use uuid::Uuid;
/// Upper bound on cards in one SBC submission (a real squad SBC is 11; consumables
/// push it higher, but 30 is well beyond any legitimate challenge and caps a DoS).
const MAX_SBC_CARDS: usize = 30;
pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result<Vec<SbcDefinition>> {
let dir = Path::new(data_dir).join("sbcs");
let mut defs = Vec::new();
@@ -46,6 +50,26 @@ pub async fn submit_sbc(
.find(|d| d.id == req.sbc_id)
.ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?;
// Reject duplicate owned-card ids and bound the list length. A repeated id would
// resolve the SAME owned card N times (each fetch succeeds), so `validate_sbc`
// counts it toward the squad size and passes, while the DELETE loop removes it
// only once — i.e. any SBC satisfiable with a single duplicated card = free
// reward. An unbounded list is also a cheap DoS.
if req.owned_card_ids.len() > MAX_SBC_CARDS {
return Err(AppError::BadRequest(format!(
"too many cards in submission ({}, max {MAX_SBC_CARDS})",
req.owned_card_ids.len()
)));
}
{
let mut seen = std::collections::HashSet::with_capacity(req.owned_card_ids.len());
if let Some(dup) = req.owned_card_ids.iter().find(|id| !seen.insert(*id)) {
return Err(AppError::BadRequest(format!(
"duplicate card in submission: {dup}"
)));
}
}
// Resolve cards from DB
let mut cards: Vec<CardDefinition> = Vec::new();
for owned_id in &req.owned_card_ids {
@@ -79,10 +103,11 @@ pub async fn submit_sbc(
let sub_id = Uuid::new_v4().to_string();
let card_ids_json = serde_json::to_string(&req.owned_card_ids)?;
sqlx::query(
"INSERT INTO sbc_submissions (id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, 1, ?)"
"INSERT INTO sbc_submissions (id, profile_id, club_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, ?, 1, ?)"
)
.bind(&sub_id)
.bind(profile_id)
.bind(club_id)
.bind(&req.sbc_id)
.bind(&card_ids_json)
.bind(chrono::Utc::now().to_rfc3339())
+10 -4
View File
@@ -1,6 +1,6 @@
use crate::{
db::Pool,
error::AppResult,
error::{AppError, AppResult},
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
services::{club, pack},
};
@@ -20,7 +20,9 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
.bind(&now)
.execute(pool)
.await?;
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
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>> {
@@ -66,7 +68,9 @@ pub async fn record_match(
.execute(pool)
.await?;
let season = fetch(pool, profile_id).await?.expect("season must exist");
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));
@@ -141,7 +145,9 @@ pub async fn record_match(
pack_awarded: pack_id.map(String::from),
};
let updated = fetch(pool, profile_id).await?.expect("season must exist");
let updated = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
Ok((updated, Some(summary)))
}
+393
View File
@@ -269,6 +269,56 @@ async fn test_sbc_submit_with_bronze_cards() {
assert!(result["reward"].is_object());
}
#[tokio::test]
async fn test_sbc_rejects_duplicate_cards() {
// Regression: a single owned card repeated to fill an SBC must be rejected. Before
// the dedup guard the same id resolved N times, passed validation, and granted the
// reward while only one card was consumed (free-reward exploit).
let app = build_test_app().await;
auth(&app, "SBCDupePlayer").await;
let (s, _) = json_post(
&app,
"/packs/buy",
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, packs_json) = json_get(&app, "/packs").await;
let pack_id = packs_json["packs"]
.as_array()
.unwrap()
.iter()
.find(|p| p["definition_id"] == "bronze_pack")
.expect("bronze pack in inventory")["pack_id"]
.as_str()
.unwrap()
.to_string();
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK);
let (_, coll) = json_get(&app, "/collection").await;
let one_card = coll["collection"].as_array().unwrap()[0]["owned_card_id"]
.as_str()
.unwrap()
.to_string();
let (s, result) = json_post(
&app,
"/sbc/submit",
serde_json::json!({
"sbc_id": "sbc_bronze_upgrade",
"owned_card_ids": vec![one_card; 11]
}),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
assert!(
result["error"].as_str().unwrap_or_default().contains("duplicate"),
"expected a duplicate-card error, got: {result}"
);
}
#[tokio::test]
async fn test_settings_read_write() {
let app = build_test_app().await;
@@ -2798,3 +2848,346 @@ async fn test_economy_purchase_items_debits_and_mints_all() {
let (_, bal) = json_get(&app, "/economy/balance").await;
assert_eq!(bal["balance"], 4200);
}
// ---- transfer-market settlement fixtures -------------------------------------
// Two parties, because a club buying its own listing is not a market path and
// would hide every ownership bug these tests exist to catch. Each party gets its
// OWN game id so that `X-OpenFUT-Game` can address either club's balance through
// the normal active-profile resolver.
/// Seeded `created_at` for the market parties. Must be LATER than the rival row
/// dated `2020-01-01` in the omitted-seller test, whose whole point is that the
/// earliest row for a game is the active one.
const MKT_TS: &str = "2026-01-01T00:00:00Z";
const SELLER_GAME: &str = "mkt-a";
const BUYER_GAME: &str = "mkt-b";
const SELLER_CLUB: &str = "mkt-club-a";
const BUYER_CLUB: &str = "mkt-club-b";
const MKT_ITEM: &str = "mkt-item-x";
const MKT_CARD: &str = "mkt-def-x";
/// The canonical sale: 15_000 gross, 750 fee (floored 5%), 14_250 to the seller.
const CANON_GROSS: i64 = 15_000;
const CANON_FEE: i64 = 750;
/// Seed one profile + its club directly. `created_at` fixes activation order
/// (the active profile for a game is its earliest row).
async fn seed_party(
pool: &sqlx::SqlitePool,
profile_id: &str,
game_id: &str,
club_id: &str,
coins: i64,
created_at: &str,
) {
sqlx::query(
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(profile_id)
.bind(profile_id)
.bind(game_id)
.bind(created_at)
.bind(created_at)
.execute(pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club_id)
.bind(profile_id)
.bind(club_id)
.bind(coins)
.bind(created_at)
.bind(created_at)
.execute(pool)
.await
.expect("club");
}
async fn seed_owned(pool: &sqlx::SqlitePool, item_id: &str, club_id: &str, card_id: &str) {
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind(item_id)
.bind(club_id)
.bind(card_id)
.bind(MKT_TS)
.execute(pool)
.await
.expect("owned card");
}
/// THE canonical fixture: seller 1_000 owning `mkt-item-x`, buyer 20_000.
async fn seed_market(pool: &sqlx::SqlitePool) {
seed_party(pool, "mkt-prof-a", SELLER_GAME, SELLER_CLUB, 1_000, MKT_TS).await;
seed_party(pool, "mkt-prof-b", BUYER_GAME, BUYER_CLUB, 20_000, MKT_TS).await;
seed_owned(pool, MKT_ITEM, SELLER_CLUB, MKT_CARD).await;
}
async fn club_coins(pool: &sqlx::SqlitePool, club_id: &str) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn item_owner(pool: &sqlx::SqlitePool, item_id: &str) -> Option<String> {
sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?")
.bind(item_id)
.fetch_optional(pool)
.await
.unwrap()
}
async fn item_row_count(pool: &sqlx::SqlitePool, item_id: &str) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
.bind(item_id)
.fetch_one(pool)
.await
.unwrap()
}
/// Total modelled coins — a two-club sale must shrink this by exactly the fee.
async fn all_club_coins(pool: &sqlx::SqlitePool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
.fetch_one(pool)
.await
.unwrap()
}
/// `json_get` for a specific game, so each seeded club can be read over HTTP.
async fn json_get_as_game(app: &axum::Router, uri: &str, game: &str) -> (StatusCode, Value) {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header("x-openfut-game", game)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, serde_json::from_slice(&body).unwrap())
}
fn settle_body(gross: i64, fee: i64, seller: Option<&str>, buyer: Option<&str>) -> Value {
let mut body = serde_json::json!({"item_id": MKT_ITEM, "gross": gross, "fee": fee});
if let Some(seller) = seller {
body["seller_club_id"] = seller.into();
}
if let Some(buyer) = buyer {
body["buyer_club_id"] = buyer.into();
}
body
}
#[tokio::test]
async fn test_economy_settle_sale_route_two_party_sale() {
let (app, pool) = build_test_app_with_pool().await;
seed_market(&pool).await;
// A third, solvent club: the double-sale attempt below must be stopped by the
// ownership CAS, not merely by the first buyer having run out of coins.
seed_party(&pool, "mkt-prof-c", "mkt-c", "mkt-club-c", 20_000, MKT_TS).await;
assert_eq!(all_club_coins(&pool).await, 41_000);
let (st, r) = json_post(
&app,
"/economy/settle-sale",
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
)
.await;
assert_eq!(st, StatusCode::OK, "{r}");
assert_eq!(r["item_id"], MKT_ITEM);
assert_eq!(r["card_id"], MKT_CARD);
assert_eq!(r["seller_club_id"], SELLER_CLUB);
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
assert_eq!(r["gross"], CANON_GROSS);
assert_eq!(r["fee"], CANON_FEE);
assert_eq!(r["proceeds"], 14_250);
assert_eq!(r["seller_balance"], 15_250);
assert_eq!(r["buyer_balance"], 5_000);
// Ownership MOVED — one row, new owner. A mint-based "buy" would leave two.
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(BUYER_CLUB)
);
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
// Same balances read back over HTTP, each club via its own game header.
let (s, seller) = json_get_as_game(&app, "/economy/balance", SELLER_GAME).await;
assert_eq!(s, StatusCode::OK, "{seller}");
assert_eq!(seller["balance"], 15_250);
let (s, buyer) = json_get_as_game(&app, "/economy/balance", BUYER_GAME).await;
assert_eq!(s, StatusCode::OK, "{buyer}");
assert_eq!(buyer["balance"], 5_000);
// The economy lost exactly the fee.
assert_eq!(all_club_coins(&pool).await, 40_250);
// Selling the same item AGAIN — to a buyer who can afford it — is rejected:
// the named seller no longer owns it, so the transfer's ownership predicate
// matches nothing and the whole transaction (including the second buyer's
// debit) rolls back.
let (st, _) = json_post(
&app,
"/economy/settle-sale",
settle_body(
CANON_GROSS,
CANON_FEE,
Some(SELLER_CLUB),
Some("mkt-club-c"),
),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
assert_eq!(club_coins(&pool, "mkt-club-c").await, 20_000);
assert_eq!(all_club_coins(&pool).await, 40_250);
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(BUYER_CLUB)
);
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
}
#[tokio::test]
async fn test_economy_settle_sale_route_omitted_buyer_settles_outside() {
let (app, pool) = build_test_app_with_pool().await;
seed_market(&pool).await;
let (st, r) = json_post(
&app,
"/economy/settle-sale",
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
)
.await;
assert_eq!(st, StatusCode::OK, "{r}");
// No modelled counterparty: an omitted buyer is OUTSIDE, never the active club.
assert!(r["buyer_club_id"].is_null());
assert!(r["buyer_balance"].is_null());
assert_eq!(r["proceeds"], 14_250);
assert_eq!(r["seller_balance"], 15_250);
// The item left the inventory entirely and no other club was debited.
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 0);
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
// Replay: the item is gone, so the seller is not credited a second time.
let (st, _) = json_post(
&app,
"/economy/settle-sale",
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
}
#[tokio::test]
async fn test_economy_settle_sale_route_omitted_seller_uses_active_club() {
let (app, pool) = build_test_app_with_pool().await;
// Seeded first (and dated earliest) so a resolution that ignored the game
// dimension would pick this club instead of the request's active one.
seed_party(
&pool,
"mkt-prof-b",
BUYER_GAME,
BUYER_CLUB,
20_000,
"2020-01-01T00:00:00Z",
)
.await;
// The active club for the default game header (fifa23), holding the item.
let session = auth(&app, "econ-settle-active").await;
let active_club = session["club"]["id"].as_str().unwrap().to_string();
seed_owned(&pool, MKT_ITEM, &active_club, MKT_CARD).await;
let (st, r) = json_post(
&app,
"/economy/settle-sale",
settle_body(CANON_GROSS, CANON_FEE, None, Some(BUYER_CLUB)),
)
.await;
assert_eq!(st, StatusCode::OK, "{r}");
assert_eq!(r["seller_club_id"], active_club);
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
// The active club starts at 5_000 and is credited gross - fee.
assert_eq!(r["seller_balance"], 19_250);
assert_eq!(r["buyer_balance"], 5_000);
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(BUYER_CLUB)
);
let (_, bal) = json_get(&app, "/economy/balance").await;
assert_eq!(bal["balance"], 19_250);
}
#[tokio::test]
async fn test_economy_settle_sale_route_rejects_fee_above_gross() {
let (app, pool) = build_test_app_with_pool().await;
seed_market(&pool).await;
let (st, _) = json_post(
&app,
"/economy/settle-sale",
settle_body(
CANON_GROSS,
CANON_GROSS + 1,
Some(SELLER_CLUB),
Some(BUYER_CLUB),
),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(SELLER_CLUB)
);
}
#[tokio::test]
async fn test_economy_settle_sale_route_rejects_unaffordable_buyer() {
let (app, pool) = build_test_app_with_pool().await;
seed_market(&pool).await;
let (st, _) = json_post(
&app,
"/economy/settle-sale",
settle_body(20_001, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Fail-closed: the debit is attempted before ownership moves, and the whole
// transaction rolls back.
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(SELLER_CLUB)
);
}
#[tokio::test]
async fn test_economy_settle_sale_route_rejects_self_dealing() {
let (app, pool) = build_test_app_with_pool().await;
seed_market(&pool).await;
let (st, _) = json_post(
&app,
"/economy/settle-sale",
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(SELLER_CLUB)),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
assert_eq!(
item_owner(&pool, MKT_ITEM).await.as_deref(),
Some(SELLER_CLUB)
);
}