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.
This commit is contained in:
@@ -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/<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
|
||||
|
||||
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<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
|
||||
|
||||
```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.
|
||||
@@ -56,6 +56,46 @@ pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 68d10658c7...31ab4a683e
@@ -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<EconomySaleReceipt, CoreError> {
|
||||
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||
|
||||
@@ -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<String>,
|
||||
pub gross: i64,
|
||||
pub fee: i64,
|
||||
pub proceeds: i64,
|
||||
pub seller_balance: i64,
|
||||
pub buyer_balance: Option<i64>,
|
||||
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<i64, CoreError>;
|
||||
/// Debit `cost` and mint several items atomically (open-on-buy Store packs).
|
||||
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
|
||||
/// 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<EconomySaleReceipt, CoreError>;
|
||||
}
|
||||
|
||||
impl HttpCoreClient {
|
||||
@@ -771,6 +813,12 @@ fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
|
||||
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
|
||||
}
|
||||
|
||||
fn json_u64(v: &Value, key: &str) -> Result<u64, CoreError> {
|
||||
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<i64, CoreError> {
|
||||
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<EconomySaleReceipt, CoreError> {
|
||||
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<EconomySaleReceipt, CoreError> {
|
||||
// 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]
|
||||
|
||||
@@ -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<i64, CoreError> {
|
||||
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<i64, CoreError> {
|
||||
@@ -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<i64, CoreError> {
|
||||
Err(CoreError::Status(500))
|
||||
}
|
||||
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||
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 -----------------------------------------
|
||||
|
||||
@@ -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<EconomySaleReceipt, CoreError> {
|
||||
if self.trip("settle_sale") {
|
||||
return Err(Self::injected());
|
||||
}
|
||||
self.inner.settle_sale(sale)
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||
|
||||
Executable
+566
@@ -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())
|
||||
Reference in New Issue
Block a user