# 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) | **UNDECIDABLE from available evidence** — CardsDLL contains no fee arithmetic, no `0.95`/`0.05` constant and no tax/net caption, so the client never computes or displays a net and there is no oracle to measure against. See `FIFA17_SOLD_WIRE_RE.md` §9. Remains a pinned CHOICE. | | The seller-facing sold wire state | **PARTIALLY RECOVERED** from the binary — see `FIFA17_SOLD_WIRE_RE.md`. Proven: no `sold` token in either vocabulary (exhaustive); `coinsProcessed` is published to Flash as `COINS_AWARDED`; a bulk clear-sold verb `DELETE …/trade/sold` exists; the seller's SOLD counter is real (atom `sold` 0x2c9 → `FUT_TF_SOLD`). Still UNDECIDABLE statically: `highest` vs `buyNow`. | | 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.