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, }) }