Files
OpenFUT/docs/MARKET_SOLD_SETTLEMENT.md
T
funman300 571c5f9261 docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.

RECOVERED FROM THE BINARY

1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
   rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
   (invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
   activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
   MUST therefore be a combination of existing atoms.

2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
     IS_GLOW = (tradeState==closed) ? bidState != none
                                    : bidState in {outbid, buyNow}
     INBOX   = bidState in {highest, buyNow}

3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
   partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
   coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
   its consumer was never found; it is now traced. DURATION also renders the
   localised FUT_AUCTION_EXPIRED when expires underflows.

4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
   yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
   as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
   values - the discrimination exists and lives entirely in unread ActionScript.
   This retires the question as a static target, and it contradicts the
   third-party lore that a seller's sold row is closed+buyNow (the corpus's own
   lifecycle table says closed+highest and assigns buyNow to the buyer).

5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
   field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
   class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
   request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
   makes sense if sold rows PERSIST in the seller's pile until cleared, which is
   incompatible with our Fix A invariant - so the sold path will require revisiting
   it under live validation.

6. The seller's SOLD counter is real, proven end to end with no inference: the hub
   tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
   tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
   caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
   count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
   Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
   bucket can never fill.

7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
   table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
   and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
   resolve a name by the pointer slot INSIDE the table, never by the first matching
   string in the binary, or you get confident nonsense.

8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
   0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
   candidate function was checked for the value-SKIP/atom-loop signature and none
   qualifies. Server-side or telemetry only; it does not carry sold state here.

TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.

Settlement NOT promoted. No production process, port or database was touched.
2026-08-18 01:32:02 +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) 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.