aa5fb2cc40
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.
379 lines
18 KiB
Python
Executable File
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())
|