Files
OpenFUT/docs/MARKET_SOLD_SETTLEMENT.md
T
funman300 f6606accb3 feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live
here.

FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and
match_reward_total because 5% is a game policy constant and Core must stay
game-neutral — Core only validates 0 <= fee <= gross and never computes a rate:

  TRANSFER_MARKET_FEE_PERCENT = 5
  transfer_market_fee(gross)  = floor(gross * 5 / 100), i128 intermediate
  seller_proceeds(gross)      = gross - fee

Integer only. Floating point is never used for coin settlement: 0.05 is not
representable in binary and a f64 round trip can create or destroy a coin at large
prices. Widening to i128 makes overflow unreachable for any i64 price, so no price
ceiling has to be assumed.

ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller
keeps the fractional coin, chosen because it makes fee + proceeds == gross hold
exactly at every input — the property the accounting invariant rests on. The
discriminating case against flooring the seller's 95% instead is a gross of 150:
this rule pays 143, the alternative 142. Nothing in the corpus or the client binary
settles which the real server did (the client is only ever told the gross; no
tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/
150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep.

HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on
HttpCoreClient as POST /economy/settle-sale. Request field names were checked
against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club
ids are OMITTED from the body (not null), which is what Core's Outside/active-club
defaults depend on, so a unit test pins that body shape. handle_market_buy is
deliberately untouched: the synthetic buy path has no counterparty, so minting there
is correct.

HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real
HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a
hard deny-list checked in three places), seeds the canonical two-party fixture,
prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31
pass. It found the rejection-precedence bug fixed in Core, and that Core's content
preflight aborts startup on an owned card whose CardDefinitionId no pack defines.

Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code
fmt-clean. Nothing deployed; no production process, port or database was touched.
2026-08-18 00:51:37 +00:00

18 KiB

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:

POST/PUT /ut/game/fifa17/trade/<id>
  -> 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:<id>", 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? Yeslistings.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:<tradeId>. 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:

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<SaleReceipt>

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

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.

TESTEDsettle_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.

TESTEDtwo_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. TESTEDsale_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.

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

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:

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.

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.