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 <noreply@anthropic.com>
This commit is contained in:
385
cmd/arcade/admin.go
Normal file
385
cmd/arcade/admin.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user