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.
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? | 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:<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
- Ownership duplication. A sold card ends up owned by both parties. Structural, not a race.
- No seller credit — selling was economically pointless.
- No fee, so no sink; every synthetic buy was pure coin destruction and every sale would have been pure creation.
- Core could not express a counterparty at all — the single-club resolver had no seller/buyer distinction to make.
UPDATE owned_cards SET club_iddid 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.
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
Outsidesale of a squadded card would fail outright on the FK; - a two-party transfer preserves the row id, so a surviving
squad_playersrow 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) andservices/market.rsboth DELETE an owned card without clearingsquad_playersand 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:
- the rejection-precedence bug above;
- Core's content preflight aborts startup when an owned card references a
CardDefinitionIdthat 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) buthandle_market_buyis 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/itemStatefor a sold row are NEEDS-LIVE and must not be guessed.docs/FIFA17_TRANSFER_MARKET_WIRE.mdrecords that nosoldtoken exists in FIFA 17'stradeStatetable (active=1 inactive=2 expired=3 closed=4). /tradePile/countssemantics unchanged. Itssoldfield stays as-is; the Core facts a correctsoldcount 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.