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.
+
+
+
+
+
+
+
+
+
QUANTUMOPS
+
+
+ house pot
+ —
+
+
+
+
+
+
+
+ house pot
+ —
+ operator funds
+
+
+ owed to players
+ —
+ liability
+
+
+ fees collected
+ —
+ —
+
+
+ margin 24h
+ —
+ —
+
+
+ players
+ —
+ —
+
+
+ books
+ —
+ —
+
+
+
+
+
+
+
+
+
+
Revenue, 30 days
+
+
+
+
Stakes vs payouts
+
+
+
+
+
Fee schedule in force
+
+
+ Rendered from the same values the server charges. If this table is
+ wrong, the code is wrong — it is not a separate document.
+
+
+
+
+
+
+
+
Accounts
+
+
+
+
+
id
name
balance
bets
+
wagered
won
net
last seen
+
+
+
+
+
+
+
+
+
+
+
Every posting
+
+ Append-only. Rows are never modified or deleted; corrections appear
+ as compensating entries.
+
+
+
+
+
id
kind
round
account
+
amount
before
after
when
+
+
+
+
+
+
+
+
+
+
+
Recent rounds
+
+ Seeds appear only after settlement. There is no control here that
+ reveals a sealed seed or alters an outcome — that is what makes the
+ fairness proof worth anything.
+
+
+
+
+
id
game
crash
players
+
staked
paid
fees
house
seed
+
+
+
+
+
+
+
+
+
+
+
+
Attention
+
+
+
+
Largest net winners
+
+
+
account
name
net
bets
+
+
+
+
+
+
+
+
+
+
+
+
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")
+ }
+}