Files
OpenFUT/scripts/sold-wire-check.py
T
funman300 aa5fb2cc40 test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the
client's reaction is attributable to neither. The env knobs were already orthogonal
(--variant and --coins-processed are independent, cp defaults to 0), but
sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly
the contaminated A/B the brief forbids. Fixed: the primary pair now holds
coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState'].

New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic
sale, then re-reads every seller-facing surface under each variant by restarting only
the host (same Core, same DBs, same sale), and diffs with explicit classification --
MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?".
Two orthogonal pairs:

  PRIMARY     bidState highest vs buyNow, coinsProcessed held at 0
  ORTHOGONAL  coinsProcessed 0 vs 1,      bidState held at highest

Result, 36/36: the ONLY finding on /tradePile is
VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs
in exactly the same one path; counts are byte-identical. The orthogonal pair's only
finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the
capture is reproducible rather than merely consistent.

Counts states the live run has to interpret, measured not guessed:
  S1  0 active + 1 sold -> count 0, selling 0, sold 1
  S2  1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode)
That divergence IS the open question for the client; production is unchanged.

scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is
built so restoration cannot depend on memory: it records the production values to a
sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing
if it is absent. It rewrites only known keys (a missing key is an error, never a
silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA
client is running because the hook reads the file at connect time.

Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface,
one file per variant so A can never overwrite B.

Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105
(pid 32188), and live-session mutual exclusion applies. The client config was NOT
touched -- the switcher's guard refused, as designed.

Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0,
counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.
2026-08-18 02:22:12 +00:00

379 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Wire-level verification of the seller-facing SOLD flow, in isolation.
Proves the harness produces a correct, authentic sold row BEFORE any operator time
is spent driving a real FIFA client. Brings up its own Core + utas-host on ephemeral
ports against throwaway databases, runs the real settlement through the
`staging_sell` binary, then reads every seller-facing surface under BOTH A/B
variants and exercises the bulk clear verb.
ISOLATION: production ports 8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216,
8080, 8081 and 8094 are in a hard deny-list checked before every bind and every
request, and nothing under /home/alex/openfut-promotion/state/ is opened.
python3 scripts/sold-wire-check.py [--keep]
"""
import argparse
import http.client
import json
import os
import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
TS = "2026-01-01T00:00:00Z"
PERSONA = "33068179"
SELLER_CLUB = "club-seller-a"
BUYER_CLUB = "club-buyer-b"
ITEM = "core-disposable-x"
CARD = "def-disposable"
TRADE_ID = "900500150"
GROSS = 150
checks = []
def check(label, ok, detail=""):
checks.append((label, bool(ok), detail))
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
return ok
def free_port():
for _ in range(200):
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
if p not in FORBIDDEN and p > 1024:
return p
raise RuntimeError("no free port")
def req(port, method, path, body=None):
assert port not in FORBIDDEN, f"refusing to contact production port {port}"
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
headers = {"X-OpenFUT-Game": "fifa17"}
if body is not None:
headers["Content-Type"] = "application/json"
c.request(method, path, body=json.dumps(body) if body is not None else None,
headers=headers)
r = c.getresponse()
raw = r.read()
c.close()
try:
return r.status, json.loads(raw)
except Exception:
return r.status, raw.decode("utf-8", "replace")
def wait_http(port, path, timeout=45, proc=None, log=None):
deadline = time.time() + timeout
while time.time() < deadline:
if proc is not None and proc.poll() is not None:
tail = ""
if log and os.path.exists(log):
tail = open(log).read()[-1500:]
raise RuntimeError(f"process exited {proc.returncode}\n{tail}")
try:
st, _ = req(port, "GET", path)
if st < 500:
return
except Exception:
time.sleep(0.25)
tail = open(log).read()[-1500:] if log and os.path.exists(log) else ""
raise RuntimeError(f"{path} on {port} never became ready\n{tail}")
def seed(db):
"""Two identities by direct SQL: Seller A (the FIFA persona) and synthetic Buyer B."""
con = sqlite3.connect(db)
for prof, club, coins, game in (
("prof-seller-a", SELLER_CLUB, 1_000, "fifa17"),
("prof-buyer-b", BUYER_CLUB, 20_000, "buyer-game"),
):
con.execute(
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)", (prof, prof, game, TS, TS))
con.execute(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)", (club, prof, club, coins, TS, TS))
con.execute(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
"VALUES (?, ?, ?, 0, ?)", (ITEM, SELLER_CLUB, CARD, TS))
con.commit()
con.close()
def owner_of(db, item):
con = sqlite3.connect(db)
row = con.execute("SELECT club_id FROM owned_cards WHERE id = ?", (item,)).fetchone()
n = con.execute("SELECT COUNT(*) FROM owned_cards WHERE id = ?", (item,)).fetchone()[0]
coins = dict(con.execute("SELECT id, coins FROM clubs").fetchall())
con.close()
return (row[0] if row else None), n, coins
def start_core(tmp, port, log):
db = os.path.join(tmp, "core.db")
pack = os.path.join(tmp, "pack.json")
with open(pack, "w") as f:
# A top-level ARRAY: Core's content-pack loader expects a sequence, not a
# map. Needed because the preflight refuses to start when an owned card
# references a CardDefinitionId no pack defines.
json.dump([{
"id": CARD, "name": "Disposable", "overall": 75, "position": "ST",
"nation": "Nation", "league": "League", "club": "Club",
"pace": 75, "shooting": 75, "passing": 75, "dribbling": 75,
"defending": 40, "physical": 70, "rarity": "gold",
"image_path": None,
}], f)
env = dict(os.environ,
LISTEN_ADDR=f"127.0.0.1:{port}",
DATABASE_URL=f"sqlite://{db}",
# Core's real data dir (read-only): it needs chemistry_styles.json
# and friends. The throwaway DB and the content pack stay in tmp.
DATA_DIR=os.path.join(REPO, "openfut-core", "data"),
OPENFUT_CONTENT_PACKS=pack)
# Migrate-only pass first: Core owns its schema, so the fixture cannot be
# seeded into an empty file. Stop it before the external writer touches the db.
with open(log, "w") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/health", proc=p, log=log)
p.terminate()
p.wait(timeout=20)
seed(db)
with open(log, "a") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/health", proc=p, log=log)
return p, db
def start_host(tmp, port, core_port, log, variant, coins_processed="0",
count_mode="active"):
with open(os.path.join(tmp, "catalog.json"), "w") as f:
# Minimal STAGING catalog. Deliberately NOT the production catalog, which
# lives under /home/alex/openfut-promotion/state/ and must never be opened.
json.dump({"schema_version": 1, "game": "fifa17",
"cards": {CARD: {"asset_id": 212188, "version": 0,
"rareflag": 1, "kind": "player"}}}, f)
env = dict(os.environ,
OPENFUT_UTAS_HOST_ADDR=f"127.0.0.1:{port}",
OPENFUT_CORE_URL=f"http://127.0.0.1:{core_port}",
# Deliberately dead: any Python fallback must fail closed and be
# visible, never silently serve production data.
OPENFUT_UTAS_PYTHON_URL="http://127.0.0.1:9",
OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon/data/tables"),
OPENFUT_IDENTITY_STORE=os.path.join(tmp, "identity.json"),
OPENFUT_PERSONA_ID=PERSONA,
OPENFUT_MARKET_DB=os.path.join(tmp, "market.db"),
OPENFUT_PILE_DB=os.path.join(tmp, "pile.db"),
OPENFUT_FIFA17_SOLD_EXPERIMENT=variant,
OPENFUT_FIFA17_SOLD_COINS_PROCESSED=coins_processed,
OPENFUT_FIFA17_SOLD_COUNT_MODE=count_mode,
OPENFUT_FIFA17_CATALOG=os.path.join(tmp, "catalog.json"),
RUST_LOG="info")
with open(log, "w") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-utas-host")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/ut/game/fifa17/tradePile/counts", proc=p, log=log)
return p
def banner(t):
print("\n" + "=" * 72)
print(f"== {t}")
print("=" * 72)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--keep", action="store_true")
args = ap.parse_args()
tmp = tempfile.mkdtemp(prefix="openfut-sold-wire-")
procs = []
try:
core_port, host_port = free_port(), free_port()
core_log = os.path.join(tmp, "core.log")
host_log = os.path.join(tmp, "host.log")
banner("ISOLATED STAGING (production untouched)")
print(f" tmp : {tmp}")
print(f" core : 127.0.0.1:{core_port}")
print(f" utas-host : 127.0.0.1:{host_port}")
print(f" forbidden : {sorted(FORBIDDEN)}")
core, core_db = start_core(tmp, core_port, core_log)
procs.append(core)
host = start_host(tmp, host_port, core_port, host_log, "highest")
procs.append(host)
print(" both ready")
bann = [l for l in open(host_log) if "sold-experiment" in l]
check("host banner names the variant", any("bidState=highest" in l for l in bann),
(bann[0].strip() if bann else "no banner"))
banner("BEFORE — seller A owns the item, nothing listed")
own, n, coins = owner_of(core_db, ITEM)
print(f" owner={own} instances={n} coins={coins}")
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
print(f" /tradePile total={pile.get('total')} counts={json.dumps(counts)}")
check("seller owns the item", own == SELLER_CLUB, str(own))
check("no rows before listing", pile.get("total") == 0)
check("sold counter starts at 0", counts.get("sold") == 0)
banner(f"LIST — authentic active listing at {GROSS} coins")
# Seed the listing directly into the staging market db: the client normally
# does this via POST /auctionhouse, which needs a wire-id mapping we do not
# have in this headless check. The LISTING SHAPE is identical either way.
mdb = os.path.join(tmp, "market.db")
con = sqlite3.connect(mdb)
con.execute(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
(TRADE_ID, CARD, ITEM, 100000178, 212188, GROSS, GROSS, "CAGE",
str(int(time.time() * 1000)), json.dumps({
"id": 100000178, "resourceId": 212188, "rating": 75,
"preferredPosition": "ST", "itemState": "forSale",
"untradeable": False, "assetId": 212188}), 3600))
con.commit()
con.close()
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
row = pile["auctionInfo"][0]
print(f" active row: tradeState={row['tradeState']} bidState={row['bidState']} "
f"expires={row['expires']} counts={json.dumps(counts)}")
check("active row is active/none", row["tradeState"] == "active" and row["bidState"] == "none")
check("counts.selling == 1 while active", counts.get("selling") == 1)
check("counts.sold still 0 while active", counts.get("sold") == 0)
banner("PURCHASE — synthetic Buyer B, through the REAL settlement path")
out = subprocess.run(
[os.path.join(REPO, "target/release/staging_sell"),
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{core_port}",
"--trade-id", TRADE_ID, "--item", ITEM,
"--seller", SELLER_CLUB, "--buyer", BUYER_CLUB, "--gross", str(GROSS)],
capture_output=True, text=True, timeout=120)
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
check("staging_sell succeeded", out.returncode == 0, f"exit {out.returncode}")
own, n, coins = owner_of(core_db, ITEM)
fee = GROSS * 5 // 100
print(f" owner={own} instances={n} coins={coins} fee={fee}")
check("ownership transferred to buyer", own == BUYER_CLUB, str(own))
check("exactly ONE authoritative instance", n == 1, str(n))
check("buyer debited gross", coins.get(BUYER_CLUB) == 20_000 - GROSS,
str(coins.get(BUYER_CLUB)))
check("seller credited net", coins.get(SELLER_CLUB) == 1_000 + GROSS - fee,
str(coins.get(SELLER_CLUB)))
check("economy shrank by exactly the fee",
21_000 - sum(coins.values()) == fee, str(21_000 - sum(coins.values())))
banner("SOLD ROW — variant A: closed / highest")
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
st3, status = req(host_port, "GET", f"/ut/game/fifa17/trade/status?tradeIds={TRADE_ID}")
a_row = pile["auctionInfo"][0] if pile.get("auctionInfo") else {}
print(" " + json.dumps(a_row, indent=2).replace("\n", "\n "))
print(f" counts={json.dumps(counts)}")
check("sold row is present in the pile", pile.get("total") == 1)
check("tradeState closed", a_row.get("tradeState") == "closed")
check("bidState highest (variant A)", a_row.get("bidState") == "highest")
check("currentBid == sale price", a_row.get("currentBid") == GROSS)
check("expires 0", a_row.get("expires") == 0)
check("twelve atoms exactly", len(a_row) == 12, str(len(a_row)))
check("counts.sold == 1", counts.get("sold") == 1)
check("counts.selling == 0", counts.get("selling") == 0)
check("/trade/status agrees", status["auctionInfo"][0]["tradeState"] == "closed"
and status["auctionInfo"][0]["bidState"] == "highest")
banner("VARIANT B — same state, restart host with closed / buyNow")
host.terminate(); host.wait(timeout=20); procs.remove(host)
# coinsProcessed is held CONSTANT at 0 here: the primary A/B must be
# attributable to bidState alone. The orthogonal coinsProcessed test is a
# separate variant pair (see scripts/sold-ab-differential.py).
host = start_host(tmp, host_port, core_port, host_log, "buyNow",
coins_processed="0", count_mode="active_plus_sold")
procs.append(host)
st, pileb = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, countsb = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
b_row = pileb["auctionInfo"][0]
print(f" bidState={b_row['bidState']} coinsProcessed={b_row['coinsProcessed']} "
f"counts={json.dumps(countsb)}")
check("bidState buyNow (variant B)", b_row.get("bidState") == "buyNow")
check("coinsProcessed held constant at 0", b_row.get("coinsProcessed") == 0)
check("count_mode active_plus_sold counts the sold row",
countsb.get("count") == 1 and countsb.get("sold") == 1,
json.dumps(countsb))
differing = sorted(k for k in a_row if a_row.get(k) != b_row.get(k))
check("A/B differ ONLY in bidState (one-field attribution)",
differing == ["bidState"], str(differing))
banner("CLEAR — the PE-proven bulk verb DELETE .../trade/sold")
pre_coins = owner_of(core_db, ITEM)[2]
st, body = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
print(f" HTTP {st} body={json.dumps(body)}")
st2, pilec = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st3, countsc = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
own2, n2, post_coins = owner_of(core_db, ITEM)
print(f" after clear: total={pilec.get('total')} counts={json.dumps(countsc)} "
f"owner={own2} instances={n2}")
check("clear acks 200 {}", st == 200 and body == {})
check("sold row gone from the pile", pilec.get("total") == 0)
check("counts.sold back to 0", countsc.get("sold") == 0)
check("clear moved NO coins", pre_coins == post_coins, f"{pre_coins} -> {post_coins}")
check("buyer still owns the item after clear", own2 == BUYER_CLUB, str(own2))
check("still exactly one instance", n2 == 1, str(n2))
st, again = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
check("clearing again is a safe no-op", st == 200)
cleared = [l for l in open(host_log) if "market-clear-sold" in l]
check("clear is logged for capture", bool(cleared),
cleared[-1].strip() if cleared else "no log line")
banner("PRODUCTION UNTOUCHED")
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
capture_output=True, text=True).stdout.strip()
check("prod-host pid 3631953 still alive", alive == "3631953", alive or "gone")
opened = subprocess.run(
["bash", "-lc",
"ls -l /proc/*/fd 2>/dev/null | grep -c openfut-promotion || true"],
capture_output=True, text=True).stdout.strip()
print(f" staging fds referencing production state: (informational) {opened}")
banner("RESULT")
passed = sum(1 for _, ok, _ in checks if ok)
print(f" {passed}/{len(checks)} checks passed")
failed = [l for l, ok, _ in checks if not ok]
if failed:
print(" FAILED: " + "; ".join(failed))
print("\n " + ("ALL CHECKS PASSED" if not failed else "FAILURES PRESENT"))
return 0 if not failed else 1
finally:
for p in procs:
try:
p.terminate(); p.wait(timeout=15)
except Exception:
try:
p.kill()
except Exception:
pass
if args.keep:
print(f"\n kept {tmp}")
else:
shutil.rmtree(tmp, ignore_errors=True)
print(f"\n removed {tmp}")
if __name__ == "__main__":
sys.exit(main())