diff --git a/docs/MARKET_SOLD_SETTLEMENT.md b/docs/MARKET_SOLD_SETTLEMENT.md new file mode 100644 index 0000000..fa309c0 --- /dev/null +++ b/docs/MARKET_SOLD_SETTLEMENT.md @@ -0,0 +1,352 @@ +# Transfer-market sold settlement + transaction fee + +How a completed market sale moves coins and ownership. Written while the +`forSale` expiry lifecycle test was running, so **nothing here has been deployed +or validated against a FIFA client** — see the confidence table. + +Every claim carries one of: + +| Tag | Meaning | +|---|---| +| **CODE** | Read directly out of the current implementation | +| **TESTED** | Pinned by a unit or integration test that fails if it regresses | +| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 knowledge, not by our client binary | +| **NEEDS-LIVE** | Requires a real FIFA 17 client to confirm; must not be treated as settled | + +--- + +## 1. What the Buy Now path did before this work + +Traced end to end, **CODE**: + +```text +POST/PUT /ut/game/fifa17/trade/ + -> openfut-utas-host/src/lib.rs route classification (EconomyRoute::MarketBuy) + -> market.rs::handle_market_buy + store.get_listing(id) market_store.rs + bid < buy_now -> echo raised bid, NO coin movement, return + store.reserve_listing(id) CAS active -> reserved + econ.balance() pre-check, 461 if short + econ.purchase_item(price, "market-buy:", listing.card_id) + -> POST {core}/economy/purchase-item + -> openfut-core services/economy.rs::purchase_item + BEGIN IMMEDIATE; debit(club, cost); add_item(club, item, card); COMMIT + store.complete_sale(id) CAS reserved -> sold + -> auctionInfo record with tradeState "closed", bidState "highest", + currentBid = price, itemData.itemState "free" +``` + +The module header at `openfut-utas-host/src/market.rs:12-16` states it plainly: +a buy-now *"debits the buyer and MINTS the won card into their club … there is no +real counterparty and no seller credit"*. + +### The ten questions, answered from code + +| # | Question | Answer (**CODE**) | +|---|---|---| +| 1 | Where are buyer coins debited? | `services/economy.rs::debit` inside `purchase_item`, one `BEGIN IMMEDIATE` transaction. Host pre-checks with `econ.balance()` only to distinguish 461 from 503. | +| 2 | Does Core know the seller? | **No.** Every `/economy/*` route resolves ONE club from the `X-OpenFUT-Game` active profile (`routes/economy.rs:23-28`); no request shape carries a counterparty. | +| 3 | Does the auction persist seller-owned item identity? | **Yes** — `listings.core_item_id` and `wire_item_id` (`market_store.rs:199-203`). The settlement inputs already existed; the buy path just ignored them. | +| 4 | Transfer or mint? | **Mint.** `purchase_item` INSERTs a new `owned_cards` row keyed `market-buy:`. The seller's row is untouched, so a sold card existed **twice**. | +| 5 | Is the seller credited? | **No.** Nowhere. | +| 6 | Any tax calculation? | **No.** Zero occurrences of `*5/100`, `0.95`, `fee`, `tax` in Core, host, adapter *or* the Python oracle. It existed only as prose in `FIFA17_HUB_BEHAVIOR_SPEC.md:884` and `FIFA17_TRANSFER_MARKET_WIRE.md:127-131`, both labelled unimplemented. | +| 7 | Auction state sequence? | `active -> reserved -> sold`, host-side (`market_store.rs`). On any Core failure `reserved -> active`. | +| 8 | Is there a `reserved` state? | **Yes**, and it is the concurrency guard, not decoration — `reserve_listing` is a `BEGIN IMMEDIATE` compare-and-swap, so of two concurrent buyers exactly one proceeds to a debit. | +| 9 | When does the item leave the seller's `/tradePile`? | When the listing stops being `active`: the pile projection emits only real active auctions (the Fix A invariant). `sold`/`cancelled` rows vanish from the pile the moment the CAS lands. | +| 10 | What makes a retry safe? | The same CAS. A second Buy Now finds the listing non-`active`, so `reserve_listing` returns `false` and the handler returns an empty `auctionInfo` — the client reads "auction gone", and no economy call is made. | + +### Deficiencies + +1. **Ownership duplication.** A sold card ends up owned by both parties. Structural, not a race. +2. **No seller credit** — selling was economically pointless. +3. **No fee**, so no sink; every synthetic buy was pure coin destruction and every sale would have been pure creation. +4. **Core could not express a counterparty at all** — the single-club resolver had no seller/buyer distinction to make. +5. `UPDATE owned_cards SET club_id` did not exist anywhere in Core: there was **no ownership-transfer primitive** to call. + +--- + +## 2. The settlement transaction + +`openfut-core/src/services/economy.rs`, alongside the existing tx-scoped +primitives so it reuses the module's proven atomicity (`pool.acquire()` + +`BEGIN IMMEDIATE` + `finish()`), **CODE**: + +```rust +pub enum SaleBuyer<'a> { Club(&'a str), Outside } +pub struct SaleTerms { pub gross: i64, pub fee: i64 } + +pub async fn settle_sale( + pool: &Pool, item_id: &str, seller_club_id: &str, + buyer: SaleBuyer<'_>, terms: SaleTerms, +) -> AppResult +``` + +One transaction: debit buyer `gross` → evict the item from every squad → +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`. It is the **only** economy route that +names clubs explicitly, because a sale has two sides and the active-profile +resolver can only describe one. Both are optional: an omitted `seller_club_id` +means the active club (the production shape — the player listed the item), and an +omitted `buyer_club_id` means `Outside`, *not* the active club, so the route can +never accidentally settle a club against itself. + +### Ownership transfer, not duplication + +```sql +UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ? +``` + +No INSERT and no DELETE on the two-party path, so the instance id, its +`chemistry_style`/`position_override`/`training_bonus` and its `acquired_at` all +survive and the inventory row count cannot change. Duplication is ruled out +structurally rather than by an assertion. `acquired_at` is deliberately preserved: +this is the same instance under new ownership, not a new acquisition. + +**TESTED** — `settle_sale_transfers_ownership_and_splits_coins` asserts both that +the item's owner changed and that `SELECT COUNT(*) FROM owned_cards` is still 1. + +### The seller is pinned, not inferred — and this was a real bug + +The first implementation derived the seller from *current* ownership, which reads +naturally ("the seller is whoever owns it") and is wrong. The concurrency test +caught it immediately: **both** racing buyers succeeded, because after the first +sale the item was owned by buyer B, so the second call read B as the seller and +cheerfully chain-sold it B → C. Core has no listing concept, so it had no way to +notice that this was a replay of one listing rather than a new sale. + +The fix is that `seller_club_id` is the club the caller **believes** owns the item, +and every ownership statement is predicated on it. That turns the `UPDATE` into an +ownership compare-and-swap which is authoritative about "this sale already +happened", independently of any caller-side listing state. Safety is not weakened: +a caller still cannot credit a club that never owned the item, because the credit +only follows a matched CAS. + +**TESTED** — `two_buyers_racing_one_item_settle_once` (exactly one winner, loser's +balance untouched, one instance, seller paid once) and +`settling_the_same_sale_twice_pays_once`. + +### Squad eviction is mandatory + +`squad_players.owned_card_id` is a foreign key onto `owned_cards(id)` and the pool +enables `foreign_keys`. Two consequences, both **CODE**: + +* an `Outside` sale of a squadded card would fail outright on the FK; +* a two-party transfer preserves the row id, so a surviving `squad_players` row + would leave the **previous** owner fielding a card they no longer own. + +So both paths evict first, and the receipt reports `squad_slots_freed`. +**TESTED** — `sale_evicts_the_item_from_the_sellers_squad`. + +> Noticed while reading, NOT fixed here (out of scope, no behaviour changed): +> `routes/cards.rs:187-192` (quick sell) and `services/market.rs` both DELETE an +> owned card without clearing `squad_players` and without a transaction, so +> quick-selling a squadded card should fail on the same FK. Worth a follow-up. + +--- + +## 3. The fee arithmetic + +Lives in `openfut-adapter-fifa17/src/fut/economy_policy.rs`, beside `pack_price` +and `match_reward_total`, because 5% is a **FIFA policy constant** and Core must +stay game-neutral. Core validates `0 <= fee <= gross` and never computes a rate. + +```rust +pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5; +pub fn transfer_market_fee(gross: i64) -> i64 { /* floor, i128 intermediate */ } +pub fn seller_proceeds(gross: i64) -> i64 { gross.max(0) - transfer_market_fee(gross) } +``` + +Integer only. Floating point is never used for coin settlement: `0.05` is not +representable in binary, and at large prices a `f64` round trip can create or +destroy a coin. The multiply widens to `i128`, so overflow is unreachable for any +`i64` price and no price ceiling has to be assumed. + +**Rounding is a CHOICE, and it is NEEDS-LIVE.** The fee is floored, so the seller +keeps the fractional coin. That was chosen because it makes + +```text +fee + proceeds == gross +``` + +hold exactly at every input, which is what the accounting invariant rests on. The +discriminating case against the alternative (flooring the seller's 95% instead) is +a gross of **150**: this rule pays **143**, the alternative pays **142**. Nothing +in the corpus or the client binary settles which the real server did — the client +is only ever told the gross, and no `tax`/`netPrice`/`sellerProceeds` wire field +exists. **A future live test should list something at 150 coins and read the +credited amount.** + +**TESTED** at `0, 1, 19, 20, 21, 39, 40, 100, 150, 200, 1_000, 15_000, +15_000_000, i64::MAX`, plus `fee + proceeds == gross` swept over 0..2000 and the +extremes, plus negative-gross rejection. + +| gross | fee | proceeds | why it is in the table | +|---|---|---|---| +| 19 | 0 | 19 | last fee-free price | +| 20 | 1 | 19 | first price that pays | +| 150 | 7 | 143 | **the NEEDS-LIVE discriminator** (alternative: 8 / 142) | +| 15,000 | 750 | 14,250 | canonical fixture | +| i64::MAX | MAX/20 | remainder | proves no overflow | + +--- + +## 4. Accounting invariant + +For `SaleBuyer::Club`, **TESTED** by `sale_conserves_coins_minus_the_fee`: + +```text +buyer_debit == seller_credit + fee +total modelled coins shrink by EXACTLY the fee +21,000 -> 20,250 (difference 750) +``` + +For `SaleBuyer::Outside` the identity deliberately differs and the doc comment +says so: the counterparty owns no `clubs` row, so nothing is debited and the +seller's proceeds **enter** the economy from outside. **TESTED** by +`settle_sale_to_outside_retires_the_item_and_pays_net`, which asserts total coins +*rise* by the proceeds. Conflating the two would look like a conservation bug. + +--- + +## 5. Rejected sales + +All **TESTED** by `invalid_sales_change_nothing`, which re-seeds a fresh fixture +per case and asserts both balances and the item's owner afterwards: + +| Case | Outcome | +|---|---| +| buyer cannot afford `gross` | rejected, nothing moves | +| buyer == seller | rejected at the Core domain boundary — not a market path, and it would otherwise debit and credit one club while destroying the fee | +| item does not exist | rejected | +| item not owned by the named seller (**includes every replay**) | rejected | +| buyer club does not exist | rejected | +| `fee > gross` | rejected | +| negative fee / negative gross | rejected | +| ownership changed mid-flight | rejected by the CAS | + +A zero-price sale is **legal** (a free transfer, no fee) — **TESTED** by +`zero_price_sale_is_a_free_transfer`. + +Auction-level states (`expired`, `cancelled`, already-`sold`) are **not** Core's +business: Core has no listing concept, and the host's `active -> reserved` CAS +already refuses those. Duplicating that check in Core would create a second +authority on auction lifecycle. + +--- + +## 6. Atomicity + +`services::economy` is the **only** module in Core that is genuinely atomic; it +uses `pool.acquire()` + raw `BEGIN IMMEDIATE` + `finish()` deliberately, because a +DEFERRED `pool.begin()` upgrades to a write at first write, where SQLite returns +`SQLITE_BUSY` immediately instead of honouring `busy_timeout` +(`services/economy.rs:218-223`). Settlement is built inside that module for +exactly this reason, so it inherits the guarantee rather than re-deriving it. + +Handlers receive a pooled clone, never `&mut SqliteConnection`, so the transaction +boundary is inside the service function. **CODE.** No externally observable state +exists where the buyer is debited without the item moving, or the seller credited +without the buyer debited — every step shares one connection inside one +transaction, and `finish()` rolls back the whole thing on any error. + +### Rejection precedence, and a test that passed for the wrong reason + +Inside one transaction the order of the steps cannot change the final state — any +error rolls everything back. It does change **which reason** a rejection carries, +and that turned out to matter. + +The first implementation debited the buyer before touching ownership, on the +reasonable-sounding grounds that an unaffordable sale should fail before ownership +moves. The isolated staging harness then showed that a replayed settlement was +refused with `insufficient balance: have 5000, need 15000` — because the buyer had +already spent the coins on the sale that succeeded. The affordability guard fired +first, so **the ownership CAS was never consulted**, and the unit test named +`settling_the_same_sale_twice_pays_once` was passing without ever exercising the +replay guard it claimed to test. + +Two fixes, both **TESTED**: + +* ownership is now judged before affordability, so a replay is reported as + `item not owned by club` (404) — the true cause; +* the replay test now asserts the error is specifically `AppError::NotFound`, and + adds a deliberately CHEAP replay (gross 100 out of the buyer's remaining 5,000) + that only ownership can possibly refuse. + +The lesson generalises: a guard that is merely *shadowed* by an earlier guard is +untested, and a green assertion on "the second call failed" says nothing about +which mechanism failed it. + +--- + +## 6b. Isolated staging harness + +`scripts/settlement-staging.py` — stdlib-only, exercises the settlement against a +**real Core over real HTTP** with zero production contact. It picks an ephemeral +loopback port (production 8099/8199/18080 sit in a hard deny-list checked in three +places), runs Core once to apply its own migrations, seeds the canonical two-party +fixture by direct SQL, relaunches Core, then prints BEFORE / PURCHASE / AFTER with +PASS-FAIL lines and cleans up in a `finally`. + +Result: **31/31 checks pass**, exit 0. + +```text +BEFORE seller 1,000 buyer 20,000 owner club-seller rows 1 total 21,000 +POST /economy/settle-sale {item_id, gross 15000, fee 750, seller, buyer} -> 200 +AFTER seller 15,250 buyer 5,000 owner club-buyer rows 1 total 20,250 + fee destroyed 750 duplicates 1 coins destroyed 750 +RETRY (identical) -> 404 item not owned by club: item-x nothing moved +REPLAY GUARD (gross 100) -> 404 item not owned by club: item-x nothing moved +``` + +Two things it found that the unit tests had not: + +1. the rejection-precedence bug above; +2. Core's **content preflight** aborts startup when an owned card references a + `CardDefinitionId` that no loaded pack defines (`content preflight failed: 1 + owned card(s) reference CardDefinitionId(s) not loaded (e.g. ["def-x"])`), so + the harness writes a one-entry content pack for its synthetic card rather than + quietly substituting a stock card id. + +--- + +## 7. What is NOT done, and why + +* **No production wire change, no deployment.** The host gained the *capability* + (`CoreEconomy::settle_sale`) but `handle_market_buy` is untouched: the synthetic + buy path (`core_item_id == None`) has no counterparty, so minting there is + correct and unchanged. +* **Nothing decides that a player's listing has sold.** That trigger is the real + remaining feature, and it needs the seller-facing sold wire representation, + which is deliberately deferred — `tradeState`/`itemState` for a sold row are + **NEEDS-LIVE** and must not be guessed. `docs/FIFA17_TRANSFER_MARKET_WIRE.md` + records that no `sold` token exists in FIFA 17's `tradeState` table + (`active=1 inactive=2 expired=3 closed=4`). +* **`/tradePile/counts` semantics unchanged.** Its `sold` field stays as-is; the + Core facts a correct `sold` count would need now exist, but the route output + must not change without client evidence. +* **The 5% rate itself is FIFA17-HISTORICAL**, and the rounding rule is + **NEEDS-LIVE** (the 150-coin discriminator above). + +## 8. Confidence summary + +| Claim | Status | +|---|---| +| The old buy path minted a duplicate and never credited a seller | **CODE** | +| No fee arithmetic existed anywhere in the project | **CODE** | +| Ownership now transfers as one row, with no duplicate possible | **TESTED** | +| Buyer debited, seller credited net, fee destroyed, in one transaction | **TESTED** | +| `buyer_debit == seller_credit + fee` | **TESTED** | +| Canonical 15,000 fixture: 20,000→5,000 / 1,000→15,250 / fee 750 | **TESTED** | +| A replayed settlement pays once | **TESTED** | +| Two racing buyers settle exactly once | **TESTED** | +| A sold card leaves the seller's lineup | **TESTED** | +| Invalid sales leave the economy unchanged | **TESTED** | +| The fee rate is 5% of gross | **FIFA17-HISTORICAL** | +| The fee is floored (150 → 143, not 142) | **NEEDS-LIVE** | +| The seller-facing sold wire state | **NEEDS-LIVE** — not implemented, not guessed | +| The client renders a settled sale correctly | **NEEDS-LIVE** — never exercised | + +No Core test here establishes anything about the FIFA 17 wire contract. They +establish the domain transaction only. diff --git a/openfut-adapter-fifa17/src/fut/economy_policy.rs b/openfut-adapter-fifa17/src/fut/economy_policy.rs index 574b695..9f99a40 100644 --- a/openfut-adapter-fifa17/src/fut/economy_policy.rs +++ b/openfut-adapter-fifa17/src/fut/economy_policy.rs @@ -56,6 +56,46 @@ pub fn pack_price(pack_id: u64) -> Option { .map(|p| p.price) } +/// FIFA 17 transfer-market fee, in PERCENT of the gross sale price. +/// +/// FIFA17-HISTORICAL: 5% is the well-documented FUT transfer tax of the era. It +/// was not recovered from our client binary — no `tax`/`netPrice`/`sellerProceeds` +/// wire field exists (`docs/FIFA17_TRANSFER_MARKET_WIRE.md`), because the client +/// is told only the GROSS price and the deduction is server-side. +pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5; + +/// Fee withheld from a completed sale of `gross` coins. +/// +/// Integer arithmetic only — coin settlement never touches floating point, where +/// `0.05` is not representable and a large price could round a coin into or out of +/// existence. Widening to `i128` for the multiply makes overflow unreachable for +/// any `i64` price, so no ceiling has to be assumed. +/// +/// ROUNDING, and it is a CHOICE that needs live confirmation: the fee is FLOORED, +/// so the seller keeps the fractional coin. That is deliberate — it makes +/// `fee + proceeds == gross` hold exactly for every input, which is the property +/// the accounting invariant depends on. The discriminating case against the +/// alternative (flooring the seller's 95% instead) is a gross of 150: this rule +/// pays 143, the alternative 142. Nothing in the corpus settles which the real +/// server did, so this MUST NOT be treated as confirmed FIFA behaviour. +/// +/// A negative gross is not a sale; it yields a zero fee rather than inventing a +/// negative one, and `settle_sale` rejects the price itself. +pub fn transfer_market_fee(gross: i64) -> i64 { + if gross <= 0 { + return 0; + } + ((gross as i128 * TRANSFER_MARKET_FEE_PERCENT as i128) / 100) as i64 +} + +/// What the seller is credited for a completed sale of `gross` coins. +/// +/// Defined as `gross - fee` rather than as its own percentage, so the pair can +/// never disagree about where a rounded coin went. +pub fn seller_proceeds(gross: i64) -> i64 { + gross.max(0) - transfer_market_fee(gross) +} + #[cfg(test)] mod tests { use super::*; @@ -84,4 +124,57 @@ mod tests { assert_eq!(pack_price(65534), None); // sentinel absent from catalogue assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable } + + /// Pins the rounding rule at every boundary the fee can turn over. If one of + /// these ever changes, monetary behaviour changed — that must be deliberate. + #[test] + fn transfer_market_fee_is_floored_five_percent() { + // (gross, expected fee, expected proceeds) + let cases = [ + (0i64, 0i64, 0i64), + (1, 0, 1), // 0.05 -> 0 + (19, 0, 19), // 0.95 -> 0, the last fee-free price + (20, 1, 19), // exactly 1.0, the first price that pays + (21, 1, 20), // 1.05 -> 1 + (39, 1, 38), // 1.95 -> 1 + (40, 2, 38), // exactly 2.0 + (100, 5, 95), + (150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142) + (200, 10, 190), + (1_000, 50, 950), + (15_000, 750, 14_250), // the canonical fixture + (15_000_000, 750_000, 14_250_000), // FUT's practical price ceiling + (i64::MAX, i64::MAX / 20, i64::MAX - i64::MAX / 20), // no overflow + ]; + for (gross, fee, proceeds) in cases { + assert_eq!(transfer_market_fee(gross), fee, "fee for gross {gross}"); + assert_eq!( + seller_proceeds(gross), + proceeds, + "proceeds for gross {gross}" + ); + } + } + + /// The property the whole settlement's accounting rests on: the fee and the + /// seller's proceeds account for the gross EXACTLY, with no coin created or + /// destroyed by rounding, at every price. + #[test] + fn fee_plus_proceeds_is_exactly_gross() { + for gross in (0i64..2_000).chain([15_000, 999_999, 15_000_000, i64::MAX]) { + assert_eq!( + transfer_market_fee(gross) + seller_proceeds(gross), + gross, + "fee + proceeds != gross at {gross}" + ); + } + } + + /// A non-sale must not invent a negative fee. + #[test] + fn negative_gross_yields_no_fee() { + assert_eq!(transfer_market_fee(-1), 0); + assert_eq!(transfer_market_fee(i64::MIN), 0); + assert_eq!(seller_proceeds(-100), 0); + } } diff --git a/openfut-core b/openfut-core index 68d1065..31ab4a6 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit 68d10658c7b6e5cae402a102127e5d2bf7efb627 +Subproject commit 31ab4a683e23758c5035a4cd80701be4183fd351 diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index 1e2f0b6..8068fd2 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -457,7 +457,7 @@ mod tests { use std::collections::HashMap; use std::sync::atomic::{AtomicI64, AtomicU32, Ordering}; - use crate::{EconomyEntitlement, EconomyPurchase}; + use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt}; // ── Recording economy double ──────────────────────────────────────────── @@ -588,6 +588,10 @@ mod tests { self.purchased.lock().push((cost, items.to_vec())); Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost) } + fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result { + // Sale settlement is not exercised by the Store/quick-sell paths. + Err(CoreError::Status(501)) + } } // ── Identity / entity / lookup doubles ────────────────────────────────── diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index b1a10f6..2f536fa 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -699,6 +699,41 @@ pub struct EconomyGrantItem { pub card_id: String, } +/// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross` +/// is what the buyer pays; `fee` is the market cut destroyed on settlement, so +/// the seller nets `gross - fee`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EconomySale<'a> { + pub item_id: &'a str, + /// Club the caller believes owns the item; `None` -> Core's game-scoped + /// active club. Core predicates the ownership move on this club, so a + /// wrong (or already-settled) seller is rejected rather than silently + /// re-run — this doubles as the replay guard. + pub seller_club_id: Option<&'a str>, + /// Acquiring club; `None` -> a counterparty outside the modelled economy: + /// nobody is debited and the item is destroyed. + pub buyer_club_id: Option<&'a str>, + pub gross: i64, + pub fee: i64, +} + +/// What Core did when settling a sale: the post-settlement balances of both +/// sides (`buyer_balance` is `None` for an outside buyer) and how many squad +/// slots the sold item vacated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EconomySaleReceipt { + pub item_id: String, + pub card_id: String, + pub seller_club_id: String, + pub buyer_club_id: Option, + pub gross: i64, + pub fee: i64, + pub proceeds: i64, + pub seller_balance: i64, + pub buyer_balance: Option, + pub squad_slots_freed: u64, +} + /// The host's authoritative economy transport to Core. Every method is a single /// durable Core transaction. **Fail-closed:** on any transport/status/parse /// error the caller MUST surface a controlled error and NEVER fall back to @@ -721,6 +756,13 @@ pub trait CoreEconomy: Send + Sync { fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result; /// Debit `cost` and mint several items atomically (open-on-buy Store packs). fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result; + /// Settle a completed market sale in ONE Core transaction: evict the item + /// from every squad, move ownership from the seller to `buyer_club_id` + /// (or destroy it for an outside buyer), debit a club buyer `gross` and + /// credit the seller `gross - fee`. Core rejects a sale whose named seller + /// does not own the item, so a replayed settlement is refused, never + /// double-paid. + fn settle_sale(&self, sale: &EconomySale<'_>) -> Result; } impl HttpCoreClient { @@ -771,6 +813,12 @@ fn json_str(v: &Value, key: &str) -> Result { .ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`"))) } +fn json_u64(v: &Value, key: &str) -> Result { + v.get(key) + .and_then(Value::as_u64) + .ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`"))) +} + impl CoreEconomy for HttpCoreClient { fn balance(&self) -> Result { json_i64(&self.economy_get("balance")?, "balance") @@ -851,6 +899,48 @@ impl CoreEconomy for HttpCoreClient { )?; json_i64(&v, "balance") } + + fn settle_sale(&self, sale: &EconomySale<'_>) -> Result { + let v = self.economy_post("settle-sale", &sale_request_body(sale))?; + Ok(EconomySaleReceipt { + item_id: json_str(&v, "item_id")?, + card_id: json_str(&v, "card_id")?, + seller_club_id: json_str(&v, "seller_club_id")?, + // Absent or null both mean "no club buyer": the outside-sale case. + buyer_club_id: v + .get("buyer_club_id") + .and_then(Value::as_str) + .map(str::to_string), + gross: json_i64(&v, "gross")?, + fee: json_i64(&v, "fee")?, + proceeds: json_i64(&v, "proceeds")?, + seller_balance: json_i64(&v, "seller_balance")?, + buyer_balance: v.get("buyer_balance").and_then(Value::as_i64), + squad_slots_freed: json_u64(&v, "squad_slots_freed")?, + }) + } +} + +/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body. +/// The two club fields are OMITTED when `None` — their absence is what selects +/// Core's defaults (the game-scoped active club as seller, a counterparty +/// outside the modelled economy as buyer). +pub fn sale_request_body(sale: &EconomySale<'_>) -> Value { + let mut body = json!({ + "item_id": sale.item_id, + "gross": sale.gross, + "fee": sale.fee, + }); + let obj = body + .as_object_mut() + .expect("the literal above is a JSON object"); + if let Some(seller) = sale.seller_club_id { + obj.insert("seller_club_id".into(), Value::from(seller)); + } + if let Some(buyer) = sale.buyer_club_id { + obj.insert("buyer_club_id".into(), Value::from(buyer)); + } + body } /// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body. @@ -3405,6 +3495,41 @@ mod tests { } Ok(self.balance) } + fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result { + // Sale settlement is not exercised through this double. + Err(CoreError::Status(501)) + } + } + + #[test] + fn settle_sale_body_omits_absent_club_ids() { + // Absent club ids are the wire signal for Core's defaults (active club + // as seller, outside counterparty as buyer), so they must not appear. + let outside = EconomySale { + item_id: "item-x", + seller_club_id: None, + buyer_club_id: None, + gross: 15_000, + fee: 750, + }; + let b = sale_request_body(&outside); + assert_eq!(b["item_id"], "item-x"); + assert_eq!(b["gross"], 15_000); + assert_eq!(b["fee"], 750); + assert!(b.get("seller_club_id").is_none()); + assert!(b.get("buyer_club_id").is_none()); + + let between_clubs = EconomySale { + seller_club_id: Some("club-seller"), + buyer_club_id: Some("club-buyer"), + ..outside + }; + let b = sale_request_body(&between_clubs); + assert_eq!(b["seller_club_id"], "club-seller"); + assert_eq!(b["buyer_club_id"], "club-buyer"); + assert_eq!(b["item_id"], "item-x"); + assert_eq!(b["gross"], 15_000); + assert_eq!(b["fee"], 750); } #[test] diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index e02fc14..5d93132 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -697,7 +697,9 @@ pub async fn handle_move_items( #[cfg(test)] mod tests { use super::*; - use crate::{EconomyEntitlement, EconomyGrantItem, EconomyPurchase}; + use crate::{ + EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt, + }; use std::collections::HashMap; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; @@ -727,9 +729,14 @@ mod tests { // ---- CoreEconomy double that debits coins and counts purchase calls ---- + /// A single coin pot stands in for the whole modelled economy: a buy-now + /// debits it, and a club-to-club settlement debits the buyer then credits + /// the seller out of the same pot, so the pot falls by exactly the fee — + /// the coins the market destroys. struct CountingEconomy { balance: AtomicI64, purchase_calls: AtomicUsize, + settle_calls: AtomicUsize, fail: bool, } impl CountingEconomy { @@ -737,6 +744,7 @@ mod tests { CountingEconomy { balance: AtomicI64::new(balance), purchase_calls: AtomicUsize::new(0), + settle_calls: AtomicUsize::new(0), fail: false, } } @@ -744,9 +752,29 @@ mod tests { CountingEconomy { balance: AtomicI64::new(0), purchase_calls: AtomicUsize::new(0), + settle_calls: AtomicUsize::new(0), fail: true, } } + /// Atomic debit: reject (and do NOT debit) if it would go negative, + /// mirroring Core's BadRequest(400) on insufficient funds. + fn debit(&self, cost: i64) -> Result { + let mut cur = self.balance.load(Ordering::SeqCst); + loop { + if cur < cost { + return Err(CoreError::Status(400)); + } + match self.balance.compare_exchange( + cur, + cur - cost, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => return Ok(cur - cost), + Err(actual) => cur = actual, + } + } + } } impl CoreEconomy for CountingEconomy { fn balance(&self) -> Result { @@ -789,23 +817,7 @@ mod tests { if self.fail { return Err(CoreError::Status(500)); } - // Atomic debit: reject (and do NOT debit) if it would go negative, - // mirroring Core's BadRequest(400) on insufficient funds. - let mut cur = self.balance.load(Ordering::SeqCst); - loop { - if cur < cost { - return Err(CoreError::Status(400)); - } - match self.balance.compare_exchange( - cur, - cur - cost, - Ordering::SeqCst, - Ordering::SeqCst, - ) { - Ok(_) => return Ok(cur - cost), - Err(actual) => cur = actual, - } - } + self.debit(cost) } fn purchase_items( &self, @@ -814,6 +826,32 @@ mod tests { ) -> Result { Err(CoreError::Status(500)) } + fn settle_sale(&self, sale: &EconomySale<'_>) -> Result { + self.settle_calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + return Err(CoreError::Status(500)); + } + // A club buyer pays out of the pot first (and can be too poor); + // an outside buyer is not modelled, so nobody is debited. + let buyer_balance = match sale.buyer_club_id { + Some(_) => Some(self.debit(sale.gross)?), + None => None, + }; + let proceeds = sale.gross - sale.fee; + let seller_balance = self.balance.fetch_add(proceeds, Ordering::SeqCst) + proceeds; + Ok(EconomySaleReceipt { + item_id: sale.item_id.to_string(), + card_id: format!("card-of:{}", sale.item_id), + seller_club_id: sale.seller_club_id.unwrap_or("active-club").to_string(), + buyer_club_id: sale.buyer_club_id.map(str::to_string), + gross: sale.gross, + fee: sale.fee, + proceeds, + seller_balance, + buyer_balance, + squad_slots_freed: 0, + }) + } } // ---- SquadWireResolver double ----------------------------------------- diff --git a/openfut-utas-host/tests/economy_failure.rs b/openfut-utas-host/tests/economy_failure.rs index 3fc9d43..41df480 100644 --- a/openfut-utas-host/tests/economy_failure.rs +++ b/openfut-utas-host/tests/economy_failure.rs @@ -31,8 +31,8 @@ use openfut_utas_host::market_store::MarketStore; use openfut_utas_host::pile_store::PileStore; use openfut_utas_host::{ build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem, - EconomyPurchase, EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, - WireResponse, + EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver, + HttpCoreClient, PassClient, Server, WireResponse, }; use parking_lot::Mutex; use serde_json::Value; @@ -134,6 +134,12 @@ impl CoreEconomy for FaultEconomy { } self.inner.purchase_items(cost, items) } + fn settle_sale(&self, sale: &EconomySale<'_>) -> Result { + if self.trip("settle_sale") { + return Err(Self::injected()); + } + self.inner.settle_sale(sale) + } } /// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can diff --git a/scripts/settlement-staging.py b/scripts/settlement-staging.py new file mode 100755 index 0000000..b576c19 --- /dev/null +++ b/scripts/settlement-staging.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +"""ISOLATED staging harness for the Core SOLD-settlement path. + +Exercises the REAL `openfut-core` binary over REAL HTTP (`POST /economy/settle-sale`) +against a THROWAWAY SQLite database in a fresh temp directory, so the transfer-market +sold path can be validated with no FIFA client, no UTAS host, and no production state. + +Isolation guarantees (all enforced below, not merely documented): + * The database is created by `tempfile.mkdtemp()` and deleted on exit (`--keep` opts out). + * The listen port is chosen by binding 127.0.0.1:0 and reading the port back; the + PRODUCTION ports 8099 (utas-host), 8199 (oracle) and 18080 (prod Core) are in a + hard deny-list and are never bound or contacted. + * Nothing under /home/alex/openfut-promotion/state/ (the live DBs) is read or written. + * Core runs with an explicit LISTEN_ADDR / DATABASE_URL / DATA_DIR and cwd set to the + temp directory, so no relative path can escape into the repo or a live database. + +Canonical two-party fixture reproduced here: + seller 1,000 coins owning `item-x`; buyer 20,000; gross 15,000; fee 750. + Expected: buyer 20,000 -> 5,000; seller 1,000 -> 15,250; owner seller -> buyer; + exactly ONE row for `item-x`; modelled coins 21,000 -> 20,250 (delta == fee). + +Usage: + python3 scripts/settlement-staging.py [--keep] [--no-build] + +Exit code 0 iff every assertion passes. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import shutil +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +# --- fixed facts about the repo (read from openfut-core/src/{main,config}.rs) ------- +# Config::from_env(): LISTEN_ADDR, DATABASE_URL, DATA_DIR, DB_MAX_CONNECTIONS. +# Plain `openfut-core` (no subcommand) runs its own migrations, then serves axum. +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CORE_BIN = os.path.join(REPO_ROOT, "target", "release", "openfut-core") +CORE_DATA_DIR = os.path.join(REPO_ROOT, "openfut-core", "data") + +# Ports owned by the live production test. Never bind, never speak to. +FORBIDDEN_PORTS = frozenset({8099, 8199, 18080}) + +GAME = "fifa17" +TS = "2026-01-01T00:00:00Z" +SELLER_PROFILE, SELLER_CLUB = "prof-seller", "club-seller" +BUYER_PROFILE, BUYER_CLUB = "prof-buyer", "club-buyer" +ITEM_ID, CARD_ID = "item-x", "def-x" +SELLER_START, BUYER_START = 1_000, 20_000 +GROSS, FEE = 15_000, 750 +PROCEEDS = GROSS - FEE + +# `app::build` runs a content preflight that rejects any owned card whose card_id is +# not a loaded CardDefinition, so the fixture's definition ships as a one-entry +# production content pack (shape: openfut-core/src/models/card.rs::CardDefinition). +CARD_PACK = [ + { + "id": CARD_ID, + "name": "Staging Fixture", + "overall": 82, + "position": "ST", + "nation": "Testland", + "league": "Staging League", + "club": "Fixture FC", + "pace": 80, + "shooting": 80, + "passing": 80, + "dribbling": 80, + "defending": 40, + "physical": 75, + "rarity": "gold", + "image_path": None, + } +] + +READY_TIMEOUT_S = 30.0 + + +# --- output helpers ----------------------------------------------------------------- + + +def banner(title: str) -> None: + print() + print("=" * 72) + print(f"== {title}") + print("=" * 72) + + +class Checks: + """Every expectation is printed where it happens AND tallied, so one failure never + hides the rest and the RESULT block stays a summary.""" + + def __init__(self) -> None: + self.results: list[tuple[str, bool, str]] = [] + + def _record(self, label: str, ok: bool, detail: str) -> bool: + self.results.append((label, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {label}: {detail}") + return ok + + def expect(self, label: str, actual, expected) -> bool: + return self._record( + label, actual == expected, f"expected {expected!r}, got {actual!r}" + ) + + def expect_true(self, label: str, ok: bool, detail: str) -> bool: + return self._record(label, bool(ok), detail) + + def report(self) -> bool: + failed = [label for label, ok, _ in self.results if not ok] + print(f" {len(self.results) - len(failed)}/{len(self.results)} checks passed") + for label, ok, detail in self.results: + if not ok: + print(f" [FAIL] {label}: {detail}") + return not failed + + +# --- port / process plumbing -------------------------------------------------------- + + +def pick_free_port() -> int: + """Bind 127.0.0.1:0, read the port back, release it. Production ports refused.""" + for _ in range(64): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + if port not in FORBIDDEN_PORTS and port > 1024: + return port + raise RuntimeError("could not obtain a free non-production port") + + +def build_core() -> None: + print("building openfut-core (release)...") + subprocess.run( + ["cargo", "build", "-p", "openfut-core", "--release"], + cwd=REPO_ROOT, + check=True, + ) + + +def pack_path(workdir: str) -> str: + return os.path.join(workdir, "content-pack.json") + + +def write_content_pack(workdir: str) -> str: + path = pack_path(workdir) + with open(path, "w") as fh: + json.dump(CARD_PACK, fh, indent=2) + return path + + +def launch_core(db_path: str, port: int, workdir: str, log_path: str, label: str): + """Start the real Core binary against the throwaway DB. Core runs its migrations.""" + if port in FORBIDDEN_PORTS: + raise RuntimeError(f"refusing to bind production port {port}") + env = dict(os.environ) + env.update( + { + "LISTEN_ADDR": f"127.0.0.1:{port}", + "DATABASE_URL": f"sqlite://{db_path}", + "DATA_DIR": CORE_DATA_DIR, + "OPENFUT_CONTENT_PACKS": pack_path(workdir), + "RUST_LOG": "openfut_core=info", + } + ) + log = open(log_path, "ab", buffering=0) + log.write(f"\n---- {label} on 127.0.0.1:{port} ----\n".encode()) + proc = subprocess.Popen( + [CORE_BIN], + cwd=workdir, # temp dir: no relative path can reach a real database + env=env, + stdout=log, + stderr=log, + ) + proc._log = log # type: ignore[attr-defined] + return proc + + +def wait_ready(proc, port: int, log_path: str) -> None: + deadline = time.monotonic() + READY_TIMEOUT_S + last = "" + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError( + f"Core exited early with code {proc.returncode}\n{tail(log_path)}" + ) + try: + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2) + conn.request("GET", "/health") + resp = conn.getresponse() + resp.read() + conn.close() + if resp.status == 200: + return + last = f"/health -> HTTP {resp.status}" + except OSError as exc: + last = f"{type(exc).__name__}: {exc}" + time.sleep(0.1) + raise RuntimeError( + f"Core on 127.0.0.1:{port} not ready after {READY_TIMEOUT_S:.0f}s " + f"(last: {last})\n{tail(log_path)}" + ) + + +def stop_core(proc) -> None: + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) + finally: + log = getattr(proc, "_log", None) + if log is not None: + log.close() + + +def tail(log_path: str, lines: int = 25) -> str: + try: + with open(log_path, "r", errors="replace") as fh: + body = fh.read().splitlines() + except OSError: + return "(no core log)" + return "--- core log tail ---\n" + "\n".join(body[-lines:]) + + +# --- database ----------------------------------------------------------------------- + + +def connect(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=10) + conn.execute("PRAGMA busy_timeout = 10000") + return conn + + +def seed(db_path: str) -> None: + """Direct-SQL fixture. Column sets match openfut-core/migrations/0001_initial.sql + (+ 0016 game_dimension's profiles.game_id). + + Two profiles share one game_id, which services::profile::create_profile would + refuse -- that limit is a service rule, not a schema constraint, and the settle + route never resolves the active profile when both club ids are named explicitly. + """ + conn = connect(db_path) + try: + with conn: + conn.executemany( + "INSERT INTO profiles (id, username, created_at, updated_at, game_id) " + "VALUES (?, ?, ?, ?, ?)", + [ + (SELLER_PROFILE, "seller", TS, TS, GAME), + (BUYER_PROFILE, "buyer", TS, TS, GAME), + ], + ) + conn.executemany( + "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + (SELLER_CLUB, SELLER_PROFILE, "Seller FC", SELLER_START, TS, TS), + (BUYER_CLUB, BUYER_PROFILE, "Buyer FC", BUYER_START, TS, TS), + ], + ) + conn.execute( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) " + "VALUES (?, ?, ?, 0, ?)", + (ITEM_ID, SELLER_CLUB, CARD_ID, TS), + ) + finally: + conn.close() + + +def snapshot(db_path: str) -> dict: + conn = connect(db_path) + try: + cur = conn.cursor() + seller = cur.execute( + "SELECT coins FROM clubs WHERE id = ?", (SELLER_CLUB,) + ).fetchone()[0] + buyer = cur.execute( + "SELECT coins FROM clubs WHERE id = ?", (BUYER_CLUB,) + ).fetchone()[0] + owners = [ + row[0] + for row in cur.execute( + "SELECT club_id FROM owned_cards WHERE id = ?", (ITEM_ID,) + ) + ] + total = cur.execute("SELECT COALESCE(SUM(coins), 0) FROM clubs").fetchone()[0] + return { + "seller_coins": seller, + "buyer_coins": buyer, + "owner": owners[0] if owners else None, + "item_rows": len(owners), + "total_coins": total, + } + finally: + conn.close() + + +def print_snapshot(title: str, snap: dict, extra: dict | None = None) -> None: + banner(title) + print(f" seller club {SELLER_CLUB!r:>14} coins : {snap['seller_coins']:>8,}") + print(f" buyer club {BUYER_CLUB!r:>14} coins : {snap['buyer_coins']:>8,}") + print(f" owner of {ITEM_ID!r:>17} : {snap['owner']}") + print(f" rows in owned_cards for {ITEM_ID!r} : {snap['item_rows']}") + print(f" total modelled coins (SUM clubs) : {snap['total_coins']:>8,}") + for key, value in (extra or {}).items(): + print(f" {key:<32} : {value}") + + +# --- HTTP --------------------------------------------------------------------------- + + +def post_settle(port: int, body: dict) -> tuple[int, str]: + if port in FORBIDDEN_PORTS: + raise RuntimeError(f"refusing to POST to production port {port}") + req = urllib.request.Request( + f"http://127.0.0.1:{port}/economy/settle-sale", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", "X-OpenFUT-Game": GAME}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status, resp.read().decode(errors="replace") + except urllib.error.HTTPError as exc: # 4xx/5xx carry the rejection body + return exc.code, exc.read().decode(errors="replace") + + +def show_response(status: int, body: str) -> dict | None: + print(f" HTTP {status}") + try: + parsed = json.loads(body) + except json.JSONDecodeError: + print(f" body (not JSON): {body!r}") + return None + print(" body:") + for line in json.dumps(parsed, indent=2, sort_keys=True).splitlines(): + print(f" {line}") + return parsed + + +# --- the run ------------------------------------------------------------------------ + + +def run(port: int, db_path: str, workdir: str, log_path: str, checks: Checks) -> None: + settle_body = { + "item_id": ITEM_ID, + "gross": GROSS, + "fee": FEE, + "seller_club_id": SELLER_CLUB, + "buyer_club_id": BUYER_CLUB, + } + + print(f" content pack : {write_content_pack(workdir)} (defines {CARD_ID})") + + banner("MIGRATE (real Core creates the throwaway schema)") + # Core owns its schema (sqlx migrations, embedded at compile time), so the + # fixture cannot be seeded into an empty file. Start Core once purely to + # migrate, stop it, THEN seed: the external writer and Core's WAL pool never + # coexist, which is the safest ordering. + migrator = launch_core(db_path, port, workdir, log_path, "migrate pass") + try: + wait_ready(migrator, port, log_path) + print(f" migrations applied; Core answered /health on 127.0.0.1:{port}") + finally: + stop_core(migrator) + print(" migrate pass stopped") + + banner("SEED (canonical two-party fixture, direct SQL)") + seed(db_path) + print(f" {SELLER_CLUB}: {SELLER_START:,} coins, owns {ITEM_ID} ({CARD_ID})") + print(f" {BUYER_CLUB}: {BUYER_START:,} coins") + + banner(f"LAUNCH Core on 127.0.0.1:{port} (db {db_path})") + server = launch_core(db_path, port, workdir, log_path, "serve pass") + try: + wait_ready(server, port, log_path) + print(f" ready: GET /health -> 200 (pid {server.pid})") + + before = snapshot(db_path) + print_snapshot("BEFORE", before) + + banner("PURCHASE POST /economy/settle-sale") + print(f" request: {json.dumps(settle_body)}") + status, body = post_settle(port, settle_body) + receipt = show_response(status, body) + + after = snapshot(db_path) + print_snapshot( + "AFTER", + after, + { + "fee withheld and destroyed": f"{FEE:,}", + f"DUPLICATE COUNT for {ITEM_ID!r}": after["item_rows"], + "coins destroyed (before-after)": f"{before['total_coins'] - after['total_coins']:,}", + }, + ) + + banner("EXPECTATIONS") + checks.expect("purchase HTTP status", status, 200) + checks.expect("buyer coins", after["buyer_coins"], BUYER_START - GROSS) + checks.expect("seller coins", after["seller_coins"], SELLER_START + PROCEEDS) + checks.expect("item owner is buyer club", after["owner"], BUYER_CLUB) + checks.expect("duplicate count == 1", after["item_rows"], 1) + checks.expect( + "total coins before", before["total_coins"], SELLER_START + BUYER_START + ) + checks.expect( + "total coins after", + after["total_coins"], + SELLER_START + BUYER_START - FEE, + ) + buyer_debit = before["buyer_coins"] - after["buyer_coins"] + seller_credit = after["seller_coins"] - before["seller_coins"] + checks.expect_true( + "conservation buyer_debit == seller_credit + fee", + buyer_debit == seller_credit + FEE, + f"{buyer_debit:,} == {seller_credit:,} + {FEE:,}", + ) + if receipt is None: + checks.expect_true("receipt is JSON", False, "response body was not JSON") + else: + checks.expect("receipt.item_id", receipt.get("item_id"), ITEM_ID) + checks.expect("receipt.card_id", receipt.get("card_id"), CARD_ID) + checks.expect( + "receipt.seller_club_id", receipt.get("seller_club_id"), SELLER_CLUB + ) + checks.expect( + "receipt.buyer_club_id", receipt.get("buyer_club_id"), BUYER_CLUB + ) + checks.expect("receipt.gross", receipt.get("gross"), GROSS) + checks.expect("receipt.fee", receipt.get("fee"), FEE) + checks.expect("receipt.proceeds", receipt.get("proceeds"), PROCEEDS) + checks.expect( + "receipt.seller_balance", + receipt.get("seller_balance"), + after["seller_coins"], + ) + checks.expect( + "receipt.buyer_balance", + receipt.get("buyer_balance"), + after["buyer_coins"], + ) + checks.expect( + "receipt.squad_slots_freed", receipt.get("squad_slots_freed"), 0 + ) + + banner("RETRY (identical request must be rejected, nothing may move)") + retry_status, retry_body = post_settle(port, settle_body) + show_response(retry_status, retry_body) + replay = snapshot(db_path) + print_snapshot("AFTER RETRY", replay) + checks.expect_true( + "retry rejected (4xx)", + 400 <= retry_status < 500, + f"HTTP {retry_status}", + ) + checks.expect("retry left buyer coins", replay["buyer_coins"], after["buyer_coins"]) + checks.expect( + "retry left seller coins", replay["seller_coins"], after["seller_coins"] + ) + checks.expect("retry left owner", replay["owner"], after["owner"]) + checks.expect("retry left total coins", replay["total_coins"], after["total_coins"]) + checks.expect("retry left one row", replay["item_rows"], 1) + + # The identical retry above is refused by the AFFORDABILITY guard, because + # settle_sale debits before it touches ownership and the buyer no longer holds + # 15,000. That alone never exercises the ownership CAS that is the actual + # replay guard, so replay the same item at a price the buyer CAN afford: the + # only thing left to stop it is "item not owned by the named seller". + banner("REPLAY GUARD (affordable re-settle must still fail on ownership)") + cheap = dict(settle_body, gross=100, fee=5) + print(f" request: {json.dumps(cheap)}") + guard_status, guard_body = post_settle(port, cheap) + show_response(guard_status, guard_body) + guarded = snapshot(db_path) + print_snapshot("AFTER REPLAY GUARD", guarded) + checks.expect("replay guard rejects with 404", guard_status, 404) + checks.expect_true( + "replay guard cites ownership", + "not owned" in guard_body, + f"body: {guard_body}", + ) + checks.expect( + "replay guard left buyer coins", guarded["buyer_coins"], after["buyer_coins"] + ) + checks.expect( + "replay guard left seller coins", + guarded["seller_coins"], + after["seller_coins"], + ) + checks.expect("replay guard left owner", guarded["owner"], after["owner"]) + checks.expect( + "replay guard left total coins", + guarded["total_coins"], + after["total_coins"], + ) + checks.expect("replay guard left one row", guarded["item_rows"], 1) + finally: + stop_core(server) + print("\n Core stopped") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--keep", action="store_true", help="keep the temp dir (and its DB + core log)" + ) + parser.add_argument( + "--no-build", action="store_true", help="use target/release/openfut-core as-is" + ) + args = parser.parse_args() + + if not args.no_build: + build_core() + if not os.path.isfile(CORE_BIN): + print(f"core binary not found: {CORE_BIN}", file=sys.stderr) + return 2 + + port = pick_free_port() + workdir = tempfile.mkdtemp(prefix="openfut-settlement-staging-") + db_path = os.path.join(workdir, "staging.db") + log_path = os.path.join(workdir, "core.log") + + banner("SETTLEMENT STAGING HARNESS (isolated; production untouched)") + print(f" temp dir : {workdir}") + print(f" throwaway db : {db_path}") + print(f" core binary : {CORE_BIN}") + print(f" port : {port} (production {sorted(FORBIDDEN_PORTS)} never bound)") + + checks = Checks() + try: + run(port, db_path, workdir, log_path, checks) + except Exception as exc: # report, then still clean up + banner("HARNESS ERROR") + print(f" {type(exc).__name__}: {exc}") + checks.expect_true("harness completed", False, f"{type(exc).__name__}: {exc}") + finally: + if args.keep: + print(f"\n --keep: leaving {workdir} in place") + else: + shutil.rmtree(workdir, ignore_errors=True) + print(f"\n removed {workdir}") + + banner("RESULT") + ok = checks.report() + print() + print(f" {'ALL CHECKS PASSED' if ok else 'FAILURES PRESENT'}") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())