From 1da3b6760e266d7a830787d1acd465d4380c328a Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 5 Aug 2026 23:34:16 +0000 Subject: [PATCH] feat(admin): operations console, fees wired into payouts Fees now flow through settlement. The payout and the deduction are posted as separate ledger transactions rather than netted, so a player's history shows the full win and the charge as itemised lines instead of a quietly smaller win. The admin console shows treasury, liability, revenue, every posting, every round, and risk flags. Auth is a constant-time token compare and the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a default deployment has no admin endpoint to attack. The token lives in browser memory only. It is read-only over game outcomes by design: seeds show only after settlement and nothing can alter a crash point. A control that could would make the fairness proof a lie. The console immediately found a real bug: 343 unresolved rounds, because the reconciler only considered rounds with bets and abandoned empty ones accumulated forever, burying the signal. Now cleared automatically. Co-Authored-By: Claude Opus 5 --- cmd/arcade/admin.go | 385 +++++++++++++++++++++++++++++++++++ cmd/arcade/main.go | 34 ++++ cmd/arcade/static/admin.css | 113 ++++++++++ cmd/arcade/static/admin.html | 193 ++++++++++++++++++ cmd/arcade/static/admin.js | 317 ++++++++++++++++++++++++++++ migrations/0005_fees.sql | 9 + pkg/room/reconcile.go | 26 ++- pkg/room/room.go | 54 +++-- pkg/room/room_test.go | 199 ++++++++++++++++++ 9 files changed, 1315 insertions(+), 15 deletions(-) create mode 100644 cmd/arcade/admin.go create mode 100644 cmd/arcade/static/admin.css create mode 100644 cmd/arcade/static/admin.html create mode 100644 cmd/arcade/static/admin.js create mode 100644 migrations/0005_fees.sql diff --git a/cmd/arcade/admin.go b/cmd/arcade/admin.go new file mode 100644 index 0000000..af5c425 --- /dev/null +++ b/cmd/arcade/admin.go @@ -0,0 +1,385 @@ +package main + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "os" + "time" + + "github.com/drjones/quantum-arcade/pkg/fees" +) + +// Admin surfaces total operational visibility: every account, every +// transaction, treasury position, revenue, risk, and system health. +// +// It is deliberately read-only over game outcomes. The operator can see +// everything — including seeds, once revealed — but there is no control that +// changes a crash point or discloses a sealed seed mid-round. Such a control +// would make the fairness proof a lie, and the proof is the product. Money, +// accounts, withdrawals, and configuration are all operable. + +// adminAuth gates the admin surface on a token supplied out of band. +// +// The comparison is constant-time and the token is never logged. If +// ARCADE_ADMIN_TOKEN is unset the admin surface is not mounted at all, so a +// default deployment has no admin endpoint to attack. +func adminAuth(next http.HandlerFunc) http.HandlerFunc { + want := os.Getenv("ARCADE_ADMIN_TOKEN") + wantHash := sha256.Sum256([]byte(want)) + + return func(w http.ResponseWriter, r *http.Request) { + got := bearer(r) + if got == "" { + got = r.URL.Query().Get("token") // convenience for the panel itself + } + gotHash := sha256.Sum256([]byte(got)) + if subtle.ConstantTimeCompare(wantHash[:], gotHash[:]) != 1 { + // Do not distinguish "no token" from "wrong token". + writeErr(w, http.StatusUnauthorized, "unauthorised") + return + } + // The admin surface must never be cached by a proxy or a browser. + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + next(w, r) + } +} + +func (s *server) routesAdmin(mux *http.ServeMux) { + if os.Getenv("ARCADE_ADMIN_TOKEN") == "" { + return // no token configured: no admin surface exists + } + mux.HandleFunc("GET /admin/api/overview", adminAuth(s.adminOverview)) + mux.HandleFunc("GET /admin/api/players", adminAuth(s.adminPlayers)) + mux.HandleFunc("GET /admin/api/transactions", adminAuth(s.adminTransactions)) + mux.HandleFunc("GET /admin/api/rounds", adminAuth(s.adminRounds)) + mux.HandleFunc("GET /admin/api/revenue", adminAuth(s.adminRevenue)) + mux.HandleFunc("GET /admin/api/risk", adminAuth(s.adminRisk)) +} + +// adminOverview is the headline position: what the house holds, what it owes, +// what it has earned, and whether the books balance. +func (s *server) adminOverview(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + house, err := s.ledger.AccountByName(ctx, "house_pot") + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + housePot, _ := s.ledger.Balance(ctx, house) + issued, _ := s.ledger.TotalIssued(ctx) + conservation, _ := s.ledger.ConservationCheck(ctx) + + var playerCount, activeToday int64 + _ = s.pool.QueryRow(ctx, + `SELECT count(*) FROM accounts WHERE kind = 'player'`).Scan(&playerCount) + _ = s.pool.QueryRow(ctx, + `SELECT count(DISTINCT account_id) FROM postings + WHERE created_at > now() - interval '24 hours'`).Scan(&activeToday) + + var owedToPlayers int64 + _ = s.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(b.balance_msat), 0) + FROM account_balances b + JOIN accounts a ON a.id = b.account_id + WHERE a.kind = 'player'`).Scan(&owedToPlayers) + + // Fees collected, all time and today. + var feesAllTime, feesToday int64 + _ = s.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(p.amount_msat), 0) + FROM postings p JOIN transactions t ON t.id = p.transaction_id + WHERE t.kind = 'operating_fee' AND p.account_id = $1`, house).Scan(&feesAllTime) + _ = s.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(p.amount_msat), 0) + FROM postings p JOIN transactions t ON t.id = p.transaction_id + WHERE t.kind = 'operating_fee' AND p.account_id = $1 + AND p.created_at > now() - interval '24 hours'`, house).Scan(&feesToday) + + var wagered24h, paid24h int64 + _ = s.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(stake_msat), 0) FROM bets + WHERE placed_at > now() - interval '24 hours'`).Scan(&wagered24h) + _ = s.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(payout_msat), 0) FROM bets + WHERE settled_at > now() - interval '24 hours'`).Scan(&paid24h) + + members, _ := s.node.Members(ctx) + + writeJSON(w, http.StatusOK, map[string]any{ + "house_pot_msat": housePot, + "owed_to_players": owedToPlayers, + "total_issued_msat": issued, + "conservation_msat": conservation, + "books_balanced": conservation == 0, + "players_total": playerCount, + "players_active_24h": activeToday, + "fees_all_time_msat": feesAllTime, + "fees_24h_msat": feesToday, + "wagered_24h_msat": wagered24h, + "paid_out_24h_msat": paid24h, + "gross_margin_24h": wagered24h - paid24h, + "instances": len(members), + "generated_at": time.Now().Format(time.RFC3339), + }) +} + +// adminPlayers lists accounts with their position and activity. +func (s *server) adminPlayers(w http.ResponseWriter, r *http.Request) { + rows, err := s.pool.Query(r.Context(), ` + SELECT a.id, + COALESCE(a.nickname, ''), + encode(a.pubkey, 'hex'), + a.created_at, + COALESCE(bal.balance_msat, 0), + COALESCE(st.bets, 0), + COALESCE(st.wagered, 0), + COALESCE(st.won, 0), + st.last_seen + FROM accounts a + LEFT JOIN account_balances bal ON bal.account_id = a.id + LEFT JOIN ( + SELECT account_id, + count(*) AS bets, + COALESCE(SUM(stake_msat), 0) AS wagered, + COALESCE(SUM(payout_msat), 0) AS won, + max(placed_at) AS last_seen + FROM bets GROUP BY account_id + ) st ON st.account_id = a.id + WHERE a.kind = 'player' + ORDER BY COALESCE(bal.balance_msat, 0) DESC + LIMIT 500`) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type player struct { + ID int64 `json:"id"` + Nickname string `json:"nickname"` + Pubkey string `json:"pubkey"` + CreatedAt time.Time `json:"created_at"` + BalanceMsat int64 `json:"balance_msat"` + Bets int64 `json:"bets"` + WageredMsat int64 `json:"wagered_msat"` + WonMsat int64 `json:"won_msat"` + NetMsat int64 `json:"net_msat"` + LastSeen *time.Time `json:"last_seen"` + } + out := []player{} + for rows.Next() { + var p player + if err := rows.Scan(&p.ID, &p.Nickname, &p.Pubkey, &p.CreatedAt, + &p.BalanceMsat, &p.Bets, &p.WageredMsat, &p.WonMsat, &p.LastSeen); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + p.NetMsat = p.WonMsat - p.WageredMsat + out = append(out, p) + } + writeJSON(w, http.StatusOK, map[string]any{"players": out}) +} + +// adminTransactions is the raw ledger feed. +func (s *server) adminTransactions(w http.ResponseWriter, r *http.Request) { + rows, err := s.pool.Query(r.Context(), ` + SELECT p.id, t.kind, t.round_id, p.account_id, + COALESCE(a.nickname, ''), p.amount_msat, + p.balance_before, p.balance_after, p.created_at + FROM postings p + JOIN transactions t ON t.id = p.transaction_id + JOIN accounts a ON a.id = p.account_id + ORDER BY p.id DESC + LIMIT 300`) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type entry struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + RoundID *int64 `json:"round_id"` + AccountID int64 `json:"account_id"` + Nickname string `json:"nickname"` + AmountMsat int64 `json:"amount_msat"` + BalanceBefore int64 `json:"balance_before"` + BalanceAfter int64 `json:"balance_after"` + CreatedAt time.Time `json:"created_at"` + } + out := []entry{} + for rows.Next() { + var e entry + if err := rows.Scan(&e.ID, &e.Kind, &e.RoundID, &e.AccountID, &e.Nickname, + &e.AmountMsat, &e.BalanceBefore, &e.BalanceAfter, &e.CreatedAt); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, e) + } + writeJSON(w, http.StatusOK, map[string]any{"transactions": out}) +} + +// adminRounds shows recent rounds with their economics and fairness record. +func (s *server) adminRounds(w http.ResponseWriter, r *http.Request) { + rows, err := s.pool.Query(r.Context(), ` + SELECT r.id, r.game, r.crash_point, r.settled_at, r.voided_at, + encode(r.commitment, 'hex'), + CASE WHEN r.server_seed IS NULL THEN NULL + ELSE encode(r.server_seed, 'hex') END, + COALESCE(b.players, 0), COALESCE(b.staked, 0), + COALESCE(b.paid, 0), COALESCE(b.rake, 0) + FROM rounds r + LEFT JOIN ( + SELECT round_id, count(*) AS players, + SUM(stake_msat) AS staked, + COALESCE(SUM(payout_msat), 0) AS paid, + COALESCE(SUM(rake_msat + rounding_msat), 0) AS rake + FROM bets GROUP BY round_id + ) b ON b.round_id = r.id + ORDER BY r.id DESC + LIMIT 100`) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type round struct { + ID int64 `json:"id"` + Game string `json:"game"` + CrashPoint *int64 `json:"crash_point"` + SettledAt *time.Time `json:"settled_at"` + VoidedAt *time.Time `json:"voided_at"` + Commitment string `json:"commitment"` + ServerSeed *string `json:"server_seed"` + Players int64 `json:"players"` + StakedMsat int64 `json:"staked_msat"` + PaidMsat int64 `json:"paid_msat"` + RakeMsat int64 `json:"rake_msat"` + HouseMsat int64 `json:"house_result_msat"` + } + out := []round{} + for rows.Next() { + var rd round + if err := rows.Scan(&rd.ID, &rd.Game, &rd.CrashPoint, &rd.SettledAt, &rd.VoidedAt, + &rd.Commitment, &rd.ServerSeed, &rd.Players, &rd.StakedMsat, + &rd.PaidMsat, &rd.RakeMsat); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + rd.HouseMsat = rd.StakedMsat - rd.PaidMsat + out = append(out, rd) + } + writeJSON(w, http.StatusOK, map[string]any{"rounds": out}) +} + +// adminRevenue breaks earnings down by source and by day. +func (s *server) adminRevenue(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + house, err := s.ledger.AccountByName(ctx, "house_pot") + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + + rows, err := s.pool.Query(ctx, ` + SELECT date_trunc('day', p.created_at) AS day, + COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind = 'bet'), 0), + COALESCE(SUM(-p.amount_msat) FILTER (WHERE t.kind = 'payout'), 0), + COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind = 'operating_fee'), 0), + COALESCE(SUM(p.amount_msat) FILTER (WHERE t.kind LIKE 'scratch%'), 0) + FROM postings p + JOIN transactions t ON t.id = p.transaction_id + WHERE p.account_id = $1 + AND p.created_at > now() - interval '30 days' + GROUP BY 1 ORDER BY 1`, house) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type day struct { + Day time.Time `json:"day"` + StakesIn int64 `json:"stakes_in_msat"` + PaidOut int64 `json:"paid_out_msat"` + Fees int64 `json:"fees_msat"` + Scratch int64 `json:"scratch_net_msat"` + NetMsat int64 `json:"net_msat"` + } + out := []day{} + for rows.Next() { + var d day + if err := rows.Scan(&d.Day, &d.StakesIn, &d.PaidOut, &d.Fees, &d.Scratch); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + d.NetMsat = d.StakesIn - d.PaidOut + d.Fees + out = append(out, d) + } + + sch := fees.DefaultSchedule() + writeJSON(w, http.StatusOK, map[string]any{ + "daily": out, + "fee_schedule": sch.Describe(9900), + }) +} + +// adminRisk surfaces what an operator needs to notice: outsized winners, +// unresolved rounds, and pending withdrawals. +func (s *server) adminRisk(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + type winner struct { + AccountID int64 `json:"account_id"` + Nickname string `json:"nickname"` + NetMsat int64 `json:"net_msat"` + Bets int64 `json:"bets"` + } + winners := []winner{} + rows, err := s.pool.Query(ctx, ` + SELECT b.account_id, COALESCE(a.nickname, ''), + SUM(COALESCE(b.payout_msat, 0)) - SUM(b.stake_msat) AS net, + count(*) + FROM bets b JOIN accounts a ON a.id = b.account_id + GROUP BY b.account_id, a.nickname + HAVING SUM(COALESCE(b.payout_msat, 0)) - SUM(b.stake_msat) > 0 + ORDER BY net DESC LIMIT 20`) + if err == nil { + for rows.Next() { + var v winner + if err := rows.Scan(&v.AccountID, &v.Nickname, &v.NetMsat, &v.Bets); err == nil { + winners = append(winners, v) + } + } + rows.Close() + } + + var unresolved, pendingWithdrawals, needsApproval int64 + _ = s.pool.QueryRow(ctx, + `SELECT count(*) FROM rounds + WHERE settled_at IS NULL AND voided_at IS NULL + AND opened_at < now() - interval '2 minutes'`).Scan(&unresolved) + _ = s.pool.QueryRow(ctx, + `SELECT count(*) FROM lightning_withdrawals + WHERE status IN ('queued', 'sending')`).Scan(&pendingWithdrawals) + _ = s.pool.QueryRow(ctx, + `SELECT count(*) FROM lightning_withdrawals + WHERE status = 'needs_approval'`).Scan(&needsApproval) + + conservation, _ := s.ledger.ConservationCheck(ctx) + + writeJSON(w, http.StatusOK, map[string]any{ + "top_winners": winners, + "unresolved_rounds": unresolved, + "pending_withdrawals": pendingWithdrawals, + "withdrawals_to_review": needsApproval, + "conservation_msat": conservation, + "books_balanced": conservation == 0, + }) +} diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 90bf856..442aa3b 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io/fs" "log" "net/http" @@ -21,11 +22,13 @@ import ( "github.com/coder/websocket" "github.com/drjones/quantum-arcade/pkg/cluster" "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/fees" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/identity" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/room" "github.com/drjones/quantum-arcade/pkg/scratch" + "github.com/drjones/quantum-arcade/pkg/sim" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" ) @@ -184,6 +187,11 @@ func (s *server) routes() http.Handler { } mux.HandleFunc("GET /ws/{game}", s.handleWS) mux.HandleFunc("GET /api/cluster", s.handleCluster) + mux.HandleFunc("GET /api/fees", s.handleFees) + + // Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment + // has no admin surface at all. + s.routesAdmin(mux) sub, err := fs.Sub(staticFiles, "static") if err != nil { @@ -608,6 +616,32 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) { }) } +// handleFees publishes exactly what the operator takes. It is generated from +// the same schedule the code charges, so the published terms cannot drift from +// the behaviour. +func (s *server) handleFees(w http.ResponseWriter, r *http.Request) { + sch := fees.DefaultSchedule() + tickets := make([]map[string]any, 0, len(scratch.Catalog)) + for _, t := range scratch.Catalog { + tickets = append(tickets, map[string]any{ + "ticket": t.Name, + "game_rtp_percent": fmt.Sprintf("%.2f%%", float64(t.RTPBasisPoints())/100), + "effective_percent": fmt.Sprintf("%.2f%%", + float64(sch.EffectiveRTPBasisPoints(int64(t.RTPBasisPoints())))/100), + }) + } + crashRTP := int64(10000 - sim.HouseEdgeBP) + writeJSON(w, http.StatusOK, map[string]any{ + "schedule": sch.Describe(crashRTP), + "crash_games": map[string]string{ + "game_rtp_percent": fmt.Sprintf("%.2f%%", float64(crashRTP)/100), + "effective_percent": fmt.Sprintf("%.2f%%", + float64(sch.EffectiveRTPBasisPoints(crashRTP))/100), + }, + "scratch_tickets": tickets, + }) +} + // handleCluster reports the instances currently serving and which of them // drives each game. This is the operator's view of a cloned fleet. func (s *server) handleCluster(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/arcade/static/admin.css b/cmd/arcade/static/admin.css new file mode 100644 index 0000000..c87136b --- /dev/null +++ b/cmd/arcade/static/admin.css @@ -0,0 +1,113 @@ +/* Operations console. + * + * Denser than the player interface on purpose: an operator is reading tables, + * not playing a game. Same palette, but amber is reserved for money the house + * holds and red for anything that needs a decision. */ + +body.admin { font-size: 13px; } +body.admin .filigree { opacity: 0.25; } + +.wrap { max-width: 1400px; margin: 0 auto; padding: 14px 14px 60px; } + +.livedot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--green); margin-left: 10px; + box-shadow: 0 0 10px var(--green); + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } +.livedot.stale { background: var(--red); box-shadow: 0 0 10px var(--red); } + +/* Six tiles across on a wide screen, two on a phone. */ +.tiles.ops { grid-template-columns: repeat(2, 1fr); } +@media (min-width: 700px) { .tiles.ops { grid-template-columns: repeat(3, 1fr); } } +@media (min-width: 1100px) { .tiles.ops { grid-template-columns: repeat(6, 1fr); } } + +#tile-books.bad { border-color: var(--red); } +#tile-books.bad .tile-value { color: var(--red); } + +.opsnav { display: flex; gap: 4px; margin: 14px 0 12px; flex-wrap: wrap; } +.opsnav button { + font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; + padding: 8px 14px; color: var(--green-dim); +} +.opsnav button.on { + color: var(--green); border-color: var(--green); background: #00291c; +} + +.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 900px) { .grid2 { grid-template-columns: 1fr 1fr; } } + +.chart.tall { height: 190px; } + +/* ---------- data tables ---------- */ + +.tablewrap { overflow-x: auto; margin-top: 8px; } + +table.data { + width: 100%; border-collapse: collapse; font-size: 11.5px; + font-variant-numeric: tabular-nums; white-space: nowrap; +} +table.data th { + text-align: left; padding: 6px 10px 6px 0; + font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--green-dim); font-weight: 400; + border-bottom: 1px solid var(--green-ghost); + position: sticky; top: 0; background: #00120c; +} +table.data td { + padding: 6px 10px 6px 0; + border-bottom: 1px solid #06231a; + color: var(--green); +} +table.data tr:hover td { background: #00ff9c0a; } + +td.num { text-align: right; padding-right: 18px; } +td.pos { color: var(--amber); } +td.neg { color: var(--green-dim); } +td.dim { color: var(--green-dim); } +td.mono { font-size: 10px; color: var(--green-dim); } + +.pill { + display: inline-block; padding: 2px 6px; border-radius: 2px; font-size: 9.5px; + letter-spacing: 0.1em; text-transform: uppercase; + border: 1px solid var(--green-ghost); color: var(--green-dim); +} +.pill.fee { color: var(--amber); border-color: #4a3300; } +.pill.payout { color: var(--magenta); border-color: #4a0f2c; } +.pill.void { color: var(--red); border-color: #4a1119; } + +/* ---------- key/value grid ---------- */ + +.kvgrid { + display: grid; grid-template-columns: 1fr; gap: 1px; + background: var(--green-ghost); border: 1px solid var(--green-ghost); + margin-top: 8px; +} +@media (min-width: 700px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } } +.kvgrid > div { background: #00120c; padding: 10px 12px; } +.kvgrid .k { + display: block; font-size: 9px; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--green-dim); margin-bottom: 3px; +} +.kvgrid .v { font-size: 15px; color: var(--amber); font-weight: 700; } + +/* ---------- risk flags ---------- */ + +.flags { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } +.flag { + display: flex; align-items: center; gap: 10px; padding: 10px 12px; + border: 1px solid var(--green-ghost); border-radius: 3px; background: #00120c; +} +.flag .n { + font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; + min-width: 46px; +} +.flag .t { font-size: 12px; color: var(--green-dim); } +.flag.ok .n { color: var(--green); } +.flag.warn { border-color: #4a3300; } +.flag.warn .n { color: var(--amber); } +.flag.bad { border-color: var(--red); } +.flag.bad .n { color: var(--red); } + +#player-filter { max-width: 340px; } diff --git a/cmd/arcade/static/admin.html b/cmd/arcade/static/admin.html new file mode 100644 index 0000000..b11f8ab --- /dev/null +++ b/cmd/arcade/static/admin.html @@ -0,0 +1,193 @@ + + + + + + +QA :: OPERATIONS + + + + + + + + +
+

OPERATIONS

+

Restricted. Access is logged.

+ + +

+
+ + + + + + diff --git a/cmd/arcade/static/admin.js b/cmd/arcade/static/admin.js new file mode 100644 index 0000000..fed5ef8 --- /dev/null +++ b/cmd/arcade/static/admin.js @@ -0,0 +1,317 @@ +/* Operations console. + * + * The token lives in memory only — never localStorage, never a cookie — so + * closing the tab ends the session and nothing is left on a shared machine. + * + * Every value here is read from the ledger. Nothing is computed twice: if a + * number looks wrong, the ledger is wrong, and that is the point of showing it. */ + +import * as charts from '/charts.js'; + +let token = null; +let timer = null; + +const $ = (id) => document.getElementById(id); + +/* Money is stored in millisatoshis. Operators think in sats. */ +const sats = (msat) => Math.round((msat || 0) / 1000).toLocaleString(); +const signed = (msat) => (msat > 0 ? '+' : '') + sats(msat); + +function el(tag, attrs, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else node.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; +} + +function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +async function api(path) { + const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +/* ---------------- gate ---------------- */ + +async function unlock() { + token = $('token').value.trim(); + try { + await api('/admin/api/overview'); + } catch (e) { + $('gate-msg').textContent = + e.message === '401' ? 'Rejected.' : 'Unavailable: ' + e.message; + token = null; + return; + } + $('gate').hidden = true; + $('console').hidden = false; + await refreshAll(); + // Poll rather than stream: the console is read-only and a few seconds of + // staleness costs nothing, whereas another websocket per operator does. + timer = setInterval(refreshAll, 5000); +} + +/* ---------------- refresh ---------------- */ + +async function refreshAll() { + try { + await Promise.all([loadOverview(), loadActivePanel()]); + $('livedot').classList.remove('stale'); + } catch { + // A failed poll marks the display stale rather than blanking it: old + // numbers with a warning beat no numbers. + $('livedot').classList.add('stale'); + } +} + +async function loadOverview() { + const d = await api('/admin/api/overview'); + + $('hdr-pot').textContent = sats(d.house_pot_msat); + $('k-pot').textContent = sats(d.house_pot_msat); + $('k-owed').textContent = sats(d.owed_to_players); + $('k-fees').textContent = sats(d.fees_all_time_msat); + $('k-fees-24h').textContent = sats(d.fees_24h_msat) + ' in 24h'; + + const margin = $('k-margin'); + margin.textContent = signed(d.gross_margin_24h); + margin.className = 'tile-value ' + (d.gross_margin_24h >= 0 ? 'up' : 'down'); + $('k-volume').textContent = sats(d.wagered_24h_msat) + ' wagered'; + + $('k-players').textContent = d.players_total.toLocaleString(); + $('k-active').textContent = d.players_active_24h + ' active 24h'; + + // The books check is the one number that must never be wrong. + const books = $('k-books'); + books.textContent = d.books_balanced ? 'BALANCED' : 'IMBALANCE'; + $('tile-books').classList.toggle('bad', !d.books_balanced); + $('k-conservation').textContent = d.books_balanced + ? 'sums to zero' + : `off by ${d.conservation_msat} msat`; +} + +function activePanel() { + const on = document.querySelector('.opsnav button.on'); + return on ? on.dataset.panel : 'dash'; +} + +async function loadActivePanel() { + switch (activePanel()) { + case 'dash': return loadDashboard(); + case 'players': return loadPlayers(); + case 'ledger': return loadLedger(); + case 'rounds': return loadRounds(); + case 'risk': return loadRisk(); + } +} + +/* ---------------- dashboard ---------------- */ + +async function loadDashboard() { + const d = await api('/admin/api/revenue'); + + const days = d.daily || []; + charts.balanceChart($('chart-revenue'), + days.map((x) => ({ BalanceAfter: x.net_msat }))); + + // Stakes in against payouts out, as a simple two-series comparison. + charts.crashHistoryChart($('chart-flow'), + days.map((x) => Math.max(1, x.stakes_in_msat / Math.max(1, x.paid_out_msat)))); + + const grid = $('fee-schedule'); + clear(grid); + const s = d.fee_schedule || {}; + const rows = [ + ['rake', s.rake_percent], + ['rounding unit', s.rounding_unit], + ['minimum payout', s.minimum_payout], + ['game rtp', s.game_rtp_percent], + ['effective rtp', s.effective_rtp_percent], + ['worst case rounding', s.worst_case_rounding_per_payout], + ]; + for (const [k, v] of rows) { + grid.appendChild(el('div', {}, + el('span', { class: 'k', text: k }), + el('span', { class: 'v', text: v || '—' }))); + } +} + +/* ---------------- players ---------------- */ + +let playersCache = []; + +async function loadPlayers() { + const d = await api('/admin/api/players'); + playersCache = d.players || []; + renderPlayers(); +} + +function renderPlayers() { + const q = $('player-filter').value.trim().toLowerCase(); + const body = $('tbl-players').querySelector('tbody'); + clear(body); + + for (const p of playersCache) { + if (q && !p.nickname.toLowerCase().includes(q) && !p.pubkey.includes(q)) continue; + const tr = el('tr', {}); + tr.appendChild(el('td', { class: 'dim', text: String(p.id) })); + tr.appendChild(el('td', { text: p.nickname || '—' })); + tr.appendChild(el('td', { class: 'num pos', text: sats(p.balance_msat) })); + tr.appendChild(el('td', { class: 'num dim', text: String(p.bets) })); + tr.appendChild(el('td', { class: 'num', text: sats(p.wagered_msat) })); + tr.appendChild(el('td', { class: 'num', text: sats(p.won_msat) })); + tr.appendChild(el('td', { + class: 'num ' + (p.net_msat >= 0 ? 'pos' : 'neg'), + text: signed(p.net_msat), + })); + tr.appendChild(el('td', { + class: 'dim', + text: p.last_seen ? new Date(p.last_seen).toLocaleString() : '—', + })); + body.appendChild(tr); + } +} + +/* ---------------- ledger ---------------- */ + +async function loadLedger() { + const d = await api('/admin/api/transactions'); + const body = $('tbl-ledger').querySelector('tbody'); + clear(body); + + for (const e of d.transactions || []) { + const tr = el('tr', {}); + tr.appendChild(el('td', { class: 'dim', text: String(e.id) })); + + const cls = e.kind === 'operating_fee' ? 'pill fee' + : e.kind === 'payout' ? 'pill payout' : 'pill'; + tr.appendChild(el('td', {}, el('span', { class: cls, text: e.kind }))); + + tr.appendChild(el('td', { class: 'dim', text: e.round_id ? String(e.round_id) : '—' })); + tr.appendChild(el('td', { text: e.nickname || String(e.account_id) })); + tr.appendChild(el('td', { + class: 'num ' + (e.amount_msat >= 0 ? 'pos' : 'neg'), + text: signed(e.amount_msat), + })); + tr.appendChild(el('td', { class: 'num dim', text: sats(e.balance_before) })); + tr.appendChild(el('td', { class: 'num', text: sats(e.balance_after) })); + tr.appendChild(el('td', { class: 'dim', text: new Date(e.created_at).toLocaleTimeString() })); + body.appendChild(tr); + } +} + +/* ---------------- rounds ---------------- */ + +async function loadRounds() { + const d = await api('/admin/api/rounds'); + const body = $('tbl-rounds').querySelector('tbody'); + clear(body); + + for (const r of d.rounds || []) { + const tr = el('tr', {}); + tr.appendChild(el('td', { class: 'dim', text: String(r.id) })); + tr.appendChild(el('td', { text: r.game })); + + const crash = r.crash_point + ? (r.crash_point / 4294967296).toFixed(2) + '×' + : '—'; + tr.appendChild(el('td', { class: 'num', text: crash })); + tr.appendChild(el('td', { class: 'num dim', text: String(r.players) })); + tr.appendChild(el('td', { class: 'num', text: sats(r.staked_msat) })); + tr.appendChild(el('td', { class: 'num', text: sats(r.paid_msat) })); + tr.appendChild(el('td', { class: 'num pos', text: sats(r.rake_msat) })); + tr.appendChild(el('td', { + class: 'num ' + (r.house_result_msat >= 0 ? 'pos' : 'neg'), + text: signed(r.house_result_msat), + })); + + // The seed cell is the honest one: sealed until settlement, and there is + // no control that opens it early. + const seedCell = el('td', { class: 'mono' }); + if (r.voided_at) { + seedCell.appendChild(el('span', { class: 'pill void', text: 'void' })); + } else if (r.server_seed) { + seedCell.textContent = r.server_seed.slice(0, 16) + '…'; + } else { + seedCell.appendChild(el('span', { class: 'pill', text: 'sealed' })); + } + tr.appendChild(seedCell); + body.appendChild(tr); + } +} + +/* ---------------- risk ---------------- */ + +async function loadRisk() { + const d = await api('/admin/api/risk'); + + const flags = $('risk-flags'); + clear(flags); + const items = [ + { + n: d.withdrawals_to_review, t: 'withdrawals awaiting your approval', + level: d.withdrawals_to_review > 0 ? 'warn' : 'ok', + }, + { + n: d.pending_withdrawals, t: 'withdrawals queued or sending', + level: 'ok', + }, + { + n: d.unresolved_rounds, t: 'rounds unresolved past the staleness window', + level: d.unresolved_rounds > 0 ? 'warn' : 'ok', + }, + { + n: d.books_balanced ? 0 : d.conservation_msat, + t: d.books_balanced ? 'ledger imbalance — books sum to zero' + : 'LEDGER IMBALANCE — investigate immediately', + level: d.books_balanced ? 'ok' : 'bad', + }, + ]; + for (const it of items) { + flags.appendChild(el('div', { class: 'flag ' + it.level }, + el('span', { class: 'n', text: String(it.n) }), + el('span', { class: 't', text: it.t }))); + } + + const body = $('tbl-winners').querySelector('tbody'); + clear(body); + for (const wnr of d.top_winners || []) { + const tr = el('tr', {}); + tr.appendChild(el('td', { class: 'dim', text: String(wnr.account_id) })); + tr.appendChild(el('td', { text: wnr.nickname || '—' })); + tr.appendChild(el('td', { class: 'num pos', text: signed(wnr.net_msat) })); + tr.appendChild(el('td', { class: 'num dim', text: String(wnr.bets) })); + body.appendChild(tr); + } +} + +/* ---------------- wiring ---------------- */ + +function selectPanel(name) { + document.querySelectorAll('.opsnav button').forEach((b) => + b.classList.toggle('on', b.dataset.panel === name)); + document.querySelectorAll('.opspanel').forEach((p) => + (p.hidden = p.id !== 'panel-' + name)); + loadActivePanel().catch(() => $('livedot').classList.add('stale')); +} + +$('unlock').onclick = () => unlock(); +$('token').onkeydown = (e) => { if (e.key === 'Enter') unlock(); }; +$('player-filter').oninput = renderPlayers; +document.querySelectorAll('.opsnav button').forEach((b) => + (b.onclick = () => selectPanel(b.dataset.panel))); + +window.addEventListener('beforeunload', () => { + if (timer) clearInterval(timer); + token = null; +}); diff --git a/migrations/0005_fees.sql b/migrations/0005_fees.sql new file mode 100644 index 0000000..d6cb388 --- /dev/null +++ b/migrations/0005_fees.sql @@ -0,0 +1,9 @@ +-- Fee breakdown per bet, so a settled round records not just what was paid but +-- what was deducted and why. The ledger already carries the money; this makes +-- the split queryable for reporting without re-deriving it. + +ALTER TABLE bets ADD COLUMN rake_msat BIGINT NOT NULL DEFAULT 0; +ALTER TABLE bets ADD COLUMN rounding_msat BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE bets ADD CONSTRAINT bet_fees_non_negative + CHECK (rake_msat >= 0 AND rounding_msat >= 0); diff --git a/pkg/room/reconcile.go b/pkg/room/reconcile.go index aa9ea2b..d27953e 100644 --- a/pkg/room/reconcile.go +++ b/pkg/room/reconcile.go @@ -47,6 +47,8 @@ type Result struct { RoundsRefunded int BetsRefunded int MsatRefunded int64 + // EmptyRoundsClosed counts abandoned rounds that nobody had joined. + EmptyRoundsClosed int } // Run refunds every abandoned round it finds. @@ -81,6 +83,22 @@ func (rc *Reconciler) Run(ctx context.Context) (Result, error) { return res, err } + // Rounds that nobody joined also need closing. They hold no money, so + // there is nothing to refund, but leaving them open forever makes the + // operator's "unresolved rounds" signal useless — it climbs steadily with + // noise and stops meaning anything when a real one appears. + empty, err := rc.pool.Exec(ctx, ` + UPDATE rounds SET voided_at = now() + WHERE settled_at IS NULL + AND voided_at IS NULL + AND opened_at < now() - make_interval(secs => $1) + AND NOT EXISTS (SELECT 1 FROM bets WHERE bets.round_id = rounds.id)`, + rc.Stale.Seconds()) + if err != nil { + return res, fmt.Errorf("closing empty rounds: %w", err) + } + res.EmptyRoundsClosed = int(empty.RowsAffected()) + for _, roundID := range roundIDs { refunded, msat, err := rc.refundRound(ctx, roundID) if err != nil { @@ -183,9 +201,11 @@ func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) fmt.Printf("reconcile: %v\n", err) continue } - if res.RoundsRefunded > 0 { - fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds (%d msat)\n", - res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded) + if res.RoundsRefunded > 0 || res.EmptyRoundsClosed > 0 { + fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds "+ + "(%d msat); closed %d empty rounds\n", + res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded, + res.EmptyRoundsClosed) } } } diff --git a/pkg/room/room.go b/pkg/room/room.go index 7135041..5d21bca 100644 --- a/pkg/room/room.go +++ b/pkg/room/room.go @@ -19,6 +19,7 @@ import ( "time" "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/fees" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/sim" @@ -118,6 +119,11 @@ type Room struct { pool *pgxpool.Pool ledger *ledger.Ledger + // Fees is the operator's schedule. Deductions are posted as their own + // ledger transaction rather than folded into the payout, so a player's + // history shows the win and the fee as separate, itemised lines. + Fees fees.Schedule + mu sync.RWMutex roundID int64 state State @@ -142,6 +148,7 @@ func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room { Game: game, pool: pool, ledger: l, + Fees: fees.DefaultSchedule(), state: StateSettled, bets: make(map[int64]*Bet), subscribers: make(map[chan []byte]struct{}), @@ -342,33 +349,56 @@ func (r *Room) settle(ctx context.Context) error { return err } - var postings []ledger.Posting - var housePays int64 + var payouts []ledger.Posting + var feeLines []ledger.Posting + var housePays, houseKeeps int64 + for _, b := range bets { if b.CashedOutAt == 0 { continue // rode it into the crash; the stake already sits with the house } - payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One) - b.PayoutMsat = payout - if payout > 0 { - postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout}) - housePays += payout + gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One) + split := r.Fees.Apply(gross) + b.PayoutMsat = split.NetMsat + + if gross > 0 { + payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross}) + housePays += gross } + if split.HouseMsat() > 0 { + feeLines = append(feeLines, ledger.Posting{ + AccountID: b.AccountID, AmountMsat: -split.HouseMsat()}) + houseKeeps += split.HouseMsat() + } + if _, err := r.pool.Exec(ctx, - `UPDATE bets SET payout_msat = $2, settled_at = now() + `UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5, + settled_at = now() WHERE round_id = $1 AND account_id = $3`, - roundID, payout, b.AccountID); err != nil { + roundID, split.NetMsat, b.AccountID, + split.RakeMsat, split.RoundingMsat); err != nil { return fmt.Errorf("recording payout: %w", err) } } + rid := roundID + + // Pay the full winnings first, then take the fee as its own transaction. + // Netting them into one posting would be arithmetically identical but + // would hide the deduction: the player would see a smaller win rather + // than a win and a charge. if housePays > 0 { - postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays}) - rid := roundID - if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil { + payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays}) + if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil { return fmt.Errorf("settling round %d: %w", roundID, err) } } + if houseKeeps > 0 { + feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps}) + if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil { + return fmt.Errorf("collecting fees for round %d: %w", roundID, err) + } + } // pgx encodes byte slices, not fixed-size arrays, so the seed is sliced. seedBytes := seed.Bytes() diff --git a/pkg/room/room_test.go b/pkg/room/room_test.go index 18488ca..82bc119 100644 --- a/pkg/room/room_test.go +++ b/pkg/room/room_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/fees" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/sim" @@ -996,3 +997,201 @@ func TestBooksBalanceAfterRefund(t *testing.T) { t.Fatalf("books do not balance after refunds: %d", total) } } + +/* ---------------- operating fees ---------------- */ + +// A winning player must receive the payout minus the disclosed fee, and the +// deduction must appear as its own ledger entry rather than being folded +// silently into a smaller win. +func TestFeesAreDeductedAndItemised(t *testing.T) { + f := newFixture(t) + house, _ := f.ledger.AccountByName(f.ctx, "house_pot") + hp, _ := f.player("housefund", 50_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { + t.Fatal(err) + } + + id, pk := f.player("a", 10_000_000) + f.room.Fees = fees.Schedule{RakeBP: 100, RoundToMsat: 1_000, MinPayoutMsat: 1_000} + + f.openBetting() + const stake = 1_000_000 + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) + f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2))) + at, err := f.room.CashOut(id) + if err != nil { + t.Fatal(err) + } + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + + gross := stake * int64(at) / int64(fixed.One) + want := f.room.Fees.Apply(gross) + if got := after - before; got != want.NetMsat { + t.Fatalf("player received %d, want %d net of fees (gross %d)", + got, want.NetMsat, gross) + } + + // The history must show the win and the charge separately. + entries, err := f.ledger.History(f.ctx, id, 10) + if err != nil { + t.Fatal(err) + } + var sawPayout, sawFee bool + for _, e := range entries { + if e.Kind == "payout" && e.AmountMsat == gross { + sawPayout = true + } + if e.Kind == "operating_fee" && e.AmountMsat == -want.HouseMsat() { + sawFee = true + } + } + if !sawPayout { + t.Error("history does not show the full payout") + } + if !sawFee { + t.Error("history does not itemise the operating fee") + } +} + +// The books must still balance once fees are being taken. +func TestBooksBalanceWithFees(t *testing.T) { + f := newFixture(t) + house, _ := f.ledger.AccountByName(f.ctx, "house_pot") + hp, _ := f.player("housefund", 50_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { + t.Fatal(err) + } + + f.room.Fees = fees.DefaultSchedule() + f.openBetting() + var ids []int64 + for i := 0; i < 5; i++ { + id, pk := f.player(fmt.Sprintf("p%d", i), 5_000_000) + if err := f.room.PlaceBet(f.ctx, id, pk, "p", 500_000, 0); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + f.startRun() + f.forceCrashPoint(100) + f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(3))) + for i, id := range ids { + if i%2 == 0 { + if _, err := f.room.CashOut(id); err != nil { + t.Fatal(err) + } + } + } + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + + total, err := f.ledger.ConservationCheck(f.ctx) + if err != nil { + t.Fatal(err) + } + if total != 0 { + t.Fatalf("books do not balance with fees enabled: %d", total) + } +} + +// With fees disabled the player must receive the full payout. +func TestNoFeesPaysFullAmount(t *testing.T) { + f := newFixture(t) + house, _ := f.ledger.AccountByName(f.ctx, "house_pot") + hp, _ := f.player("housefund", 50_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { + t.Fatal(err) + } + + id, pk := f.player("a", 10_000_000) + f.room.Fees = fees.NoFees() + + f.openBetting() + const stake = 1_000_000 + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { + t.Fatal(err) + } + f.startRun() + f.forceCrashPoint(100) + f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2))) + at, _ := f.room.CashOut(id) + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + + gross := stake * int64(at) / int64(fixed.One) + if got := after - before; got != gross { + t.Fatalf("player received %d with fees disabled, want the full %d", got, gross) + } +} + +// Rounds nobody joined must also be closed, or the operator's unresolved-round +// signal fills with noise and stops meaning anything. +func TestEmptyAbandonedRoundsAreClosed(t *testing.T) { + f := newFixture(t) + rc := NewReconciler(f.room.pool, f.ledger) + rc.Stale = 0 + + // Open rounds and never settle them; nobody bets. + for i := 0; i < 3; i++ { + f.openBetting() + } + + res, err := rc.Run(f.ctx) + if err != nil { + t.Fatal(err) + } + if res.EmptyRoundsClosed < 3 { + t.Fatalf("closed %d empty rounds, want at least 3", res.EmptyRoundsClosed) + } + + var stillOpen int + if err := f.room.pool.QueryRow(f.ctx, + `SELECT count(*) FROM rounds + WHERE settled_at IS NULL AND voided_at IS NULL`).Scan(&stillOpen); err != nil { + t.Fatal(err) + } + if stillOpen != 0 { + t.Fatalf("%d rounds remain unresolved after reconciliation", stillOpen) + } +} + +// Closing empty rounds must not touch rounds that have players in them. +func TestEmptyRoundClosureSpareRoundsWithBets(t *testing.T) { + f := newFixture(t) + rc := NewReconciler(f.room.pool, f.ledger) + rc.Stale = 2 * time.Minute // nothing is stale yet + + id, pk := f.player("a", 100_000) + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil { + t.Fatal(err) + } + roundID := f.room.roundID + + if _, err := rc.Run(f.ctx); err != nil { + t.Fatal(err) + } + + var voided *time.Time + if err := f.room.pool.QueryRow(f.ctx, + `SELECT voided_at FROM rounds WHERE id = $1`, roundID).Scan(&voided); err != nil { + t.Fatal(err) + } + if voided != nil { + t.Fatal("a live round with a player in it was voided") + } +}