Compare commits
3 Commits
3bdb518f9c
...
1da3b6760e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da3b6760e | ||
|
|
e70258c54d | ||
|
|
f097721304 |
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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
113
cmd/arcade/static/admin.css
Normal file
113
cmd/arcade/static/admin.css
Normal file
@@ -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; }
|
||||
193
cmd/arcade/static/admin.html
Normal file
193
cmd/arcade/static/admin.html
Normal file
@@ -0,0 +1,193 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>QA :: OPERATIONS</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body class="admin">
|
||||
|
||||
<svg class="filigree" aria-hidden="true">
|
||||
<defs>
|
||||
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||
<circle cx="30" cy="26" r="1.6"/>
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||
</svg>
|
||||
|
||||
<!-- Gate. The token is held in memory only; it is never written to storage,
|
||||
so closing the tab ends the session. -->
|
||||
<section class="panel center" id="gate">
|
||||
<h1>OPERATIONS</h1>
|
||||
<p class="muted small">Restricted. Access is logged.</p>
|
||||
<input id="token" type="password" placeholder="operator token" autocomplete="off">
|
||||
<button class="primary" id="unlock">Authenticate</button>
|
||||
<p class="fineprint" id="gate-msg"></p>
|
||||
</section>
|
||||
|
||||
<div id="console" hidden>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">QUANTUM<span>OPS</span></div>
|
||||
<div class="livedot" id="livedot" title="auto-refreshing"></div>
|
||||
<div class="balance">
|
||||
<span class="label">house pot</span>
|
||||
<span class="value" id="hdr-pot">—</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Headline position -->
|
||||
<section class="wrap">
|
||||
<div class="tiles ops">
|
||||
<div class="tile">
|
||||
<span class="tile-label">house pot</span>
|
||||
<span class="tile-value" id="k-pot">—</span>
|
||||
<span class="tile-sub">operator funds</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">owed to players</span>
|
||||
<span class="tile-value" id="k-owed">—</span>
|
||||
<span class="tile-sub">liability</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">fees collected</span>
|
||||
<span class="tile-value up" id="k-fees">—</span>
|
||||
<span class="tile-sub" id="k-fees-24h">—</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">margin 24h</span>
|
||||
<span class="tile-value" id="k-margin">—</span>
|
||||
<span class="tile-sub" id="k-volume">—</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">players</span>
|
||||
<span class="tile-value" id="k-players">—</span>
|
||||
<span class="tile-sub" id="k-active">—</span>
|
||||
</div>
|
||||
<div class="tile" id="tile-books">
|
||||
<span class="tile-label">books</span>
|
||||
<span class="tile-value" id="k-books">—</span>
|
||||
<span class="tile-sub" id="k-conservation">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="opsnav" id="opsnav">
|
||||
<button class="on" data-panel="dash">Dashboard</button>
|
||||
<button data-panel="players">Players</button>
|
||||
<button data-panel="ledger">Ledger</button>
|
||||
<button data-panel="rounds">Rounds</button>
|
||||
<button data-panel="risk">Risk</button>
|
||||
</nav>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<section class="opspanel" id="panel-dash">
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<h2>Revenue, 30 days</h2>
|
||||
<div class="chart tall" id="chart-revenue"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Stakes vs payouts</h2>
|
||||
<div class="chart tall" id="chart-flow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Fee schedule in force</h2>
|
||||
<div id="fee-schedule" class="kvgrid"></div>
|
||||
<p class="muted small">
|
||||
Rendered from the same values the server charges. If this table is
|
||||
wrong, the code is wrong — it is not a separate document.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PLAYERS -->
|
||||
<section class="opspanel" id="panel-players" hidden>
|
||||
<div class="card">
|
||||
<h2>Accounts</h2>
|
||||
<input id="player-filter" placeholder="filter by name or key" autocomplete="off">
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-players">
|
||||
<thead><tr>
|
||||
<th>id</th><th>name</th><th>balance</th><th>bets</th>
|
||||
<th>wagered</th><th>won</th><th>net</th><th>last seen</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LEDGER -->
|
||||
<section class="opspanel" id="panel-ledger" hidden>
|
||||
<div class="card">
|
||||
<h2>Every posting</h2>
|
||||
<p class="muted small">
|
||||
Append-only. Rows are never modified or deleted; corrections appear
|
||||
as compensating entries.
|
||||
</p>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-ledger">
|
||||
<thead><tr>
|
||||
<th>id</th><th>kind</th><th>round</th><th>account</th>
|
||||
<th>amount</th><th>before</th><th>after</th><th>when</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ROUNDS -->
|
||||
<section class="opspanel" id="panel-rounds" hidden>
|
||||
<div class="card">
|
||||
<h2>Recent rounds</h2>
|
||||
<p class="muted small">
|
||||
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.
|
||||
</p>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-rounds">
|
||||
<thead><tr>
|
||||
<th>id</th><th>game</th><th>crash</th><th>players</th>
|
||||
<th>staked</th><th>paid</th><th>fees</th><th>house</th><th>seed</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RISK -->
|
||||
<section class="opspanel" id="panel-risk" hidden>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<h2>Attention</h2>
|
||||
<div id="risk-flags" class="flags"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Largest net winners</h2>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-winners">
|
||||
<thead><tr><th>account</th><th>name</th><th>net</th><th>bets</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
317
cmd/arcade/static/admin.js
Normal file
317
cmd/arcade/static/admin.js
Normal file
@@ -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;
|
||||
});
|
||||
39
migrations/0004_lightning.sql
Normal file
39
migrations/0004_lightning.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Lightning deposits and withdrawals.
|
||||
--
|
||||
-- Invoices key on payment_hash, which is what makes crediting idempotent: a
|
||||
-- node that reports the same settlement twice cannot produce two credits.
|
||||
|
||||
CREATE TABLE lightning_invoices (
|
||||
payment_hash TEXT PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||
bolt11 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
-- Set exactly once, when the payment is credited to the ledger.
|
||||
credited_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX lightning_invoices_account_idx ON lightning_invoices (account_id, created_at DESC);
|
||||
CREATE INDEX lightning_invoices_pending_idx ON lightning_invoices (created_at)
|
||||
WHERE credited_at IS NULL;
|
||||
|
||||
CREATE TYPE withdrawal_status AS ENUM
|
||||
('queued', 'needs_approval', 'sending', 'paid', 'failed', 'rejected');
|
||||
|
||||
CREATE TABLE lightning_withdrawals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
bolt11 TEXT NOT NULL,
|
||||
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||
status withdrawal_status NOT NULL,
|
||||
payment_hash TEXT,
|
||||
fee_msat BIGINT,
|
||||
failure TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX lightning_withdrawals_account_idx ON lightning_withdrawals (account_id, id DESC);
|
||||
CREATE INDEX lightning_withdrawals_pending_idx ON lightning_withdrawals (id)
|
||||
WHERE status IN ('queued', 'needs_approval', 'sending');
|
||||
9
migrations/0005_fees.sql
Normal file
9
migrations/0005_fees.sql
Normal file
@@ -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);
|
||||
178
pkg/fees/fees.go
Normal file
178
pkg/fees/fees.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Package fees computes the operator's cut and the rounding residue.
|
||||
//
|
||||
// Two deductions apply to money leaving the house:
|
||||
//
|
||||
// - A rake: a percentage of each payout, disclosed as a rate.
|
||||
// - Rounding: payouts are floored to a whole unit, and the fractional
|
||||
// remainder stays with the house.
|
||||
//
|
||||
// Neither is hidden, and neither can be hidden. Every millisatoshi in this
|
||||
// system is a double-entry posting, and the ledger's conservation check fails
|
||||
// if any amount is unaccounted for. So a rake and a rounding residue must each
|
||||
// appear as their own posting against the house — which means they also appear
|
||||
// in the player's own transaction history, itemised. The architecture makes
|
||||
// silent skimming impossible rather than merely discouraged.
|
||||
//
|
||||
// The consequence worth stating plainly: a rake reduces the real return to
|
||||
// player. A game advertising 99% that then takes 1% of wins does not return
|
||||
// 99%. EffectiveRTPBasisPoints computes what players actually get, and the
|
||||
// disclosure page is generated from it, so the published figure cannot drift
|
||||
// from the code.
|
||||
package fees
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Schedule is the operator's fee configuration.
|
||||
type Schedule struct {
|
||||
// RakeBP is taken from each payout, in basis points. 100 = 1%.
|
||||
RakeBP int64
|
||||
|
||||
// RoundToMsat floors payouts to a multiple of this. 1000 rounds to whole
|
||||
// satoshis. Set to 1 (or 0) to disable rounding entirely.
|
||||
RoundToMsat int64
|
||||
|
||||
// MinPayoutMsat is the smallest payout worth making. Below it, rounding
|
||||
// would consume the whole amount, so the payout is suppressed and the
|
||||
// player told why rather than silently paid nothing.
|
||||
MinPayoutMsat int64
|
||||
}
|
||||
|
||||
// DefaultSchedule is deliberately small. The rake is the operator's revenue;
|
||||
// the rounding is a rounding, not a second rake.
|
||||
func DefaultSchedule() Schedule {
|
||||
return Schedule{
|
||||
RakeBP: 100, // 1% of winnings
|
||||
RoundToMsat: 1_000, // whole satoshis
|
||||
MinPayoutMsat: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
// NoFees disables both deductions, for testing and for a house that wants to
|
||||
// run the arcade at cost.
|
||||
func NoFees() Schedule {
|
||||
return Schedule{RakeBP: 0, RoundToMsat: 1, MinPayoutMsat: 0}
|
||||
}
|
||||
|
||||
// Split is the breakdown of a single payout.
|
||||
type Split struct {
|
||||
// GrossMsat is what the player won before deductions.
|
||||
GrossMsat int64
|
||||
// RakeMsat is the operator's percentage.
|
||||
RakeMsat int64
|
||||
// RoundingMsat is the fraction left behind by flooring to RoundToMsat.
|
||||
RoundingMsat int64
|
||||
// NetMsat is what the player actually receives.
|
||||
NetMsat int64
|
||||
}
|
||||
|
||||
// HouseMsat is everything the operator keeps from this payout.
|
||||
func (s Split) HouseMsat() int64 { return s.RakeMsat + s.RoundingMsat }
|
||||
|
||||
// Valid reports whether the split accounts for every millisatoshi. A split
|
||||
// that does not balance would corrupt the ledger, so this is asserted before
|
||||
// any posting is written.
|
||||
func (s Split) Valid() bool {
|
||||
return s.NetMsat+s.RakeMsat+s.RoundingMsat == s.GrossMsat &&
|
||||
s.NetMsat >= 0 && s.RakeMsat >= 0 && s.RoundingMsat >= 0
|
||||
}
|
||||
|
||||
// Apply splits a gross payout into the player's share and the house's.
|
||||
func (sch Schedule) Apply(grossMsat int64) Split {
|
||||
if grossMsat <= 0 {
|
||||
return Split{}
|
||||
}
|
||||
|
||||
rake := mulDivFloor(grossMsat, sch.RakeBP, 10000)
|
||||
afterRake := grossMsat - rake
|
||||
|
||||
unit := sch.RoundToMsat
|
||||
if unit < 1 {
|
||||
unit = 1
|
||||
}
|
||||
net := afterRake / unit * unit
|
||||
rounding := afterRake - net
|
||||
|
||||
// Below the minimum, paying out costs more in dust than it delivers.
|
||||
// Suppressing it must still be accounted: the amount goes to the house
|
||||
// and is visible as such, not quietly dropped.
|
||||
if net < sch.MinPayoutMsat {
|
||||
rounding += net
|
||||
net = 0
|
||||
}
|
||||
|
||||
s := Split{
|
||||
GrossMsat: grossMsat,
|
||||
RakeMsat: rake,
|
||||
RoundingMsat: rounding,
|
||||
NetMsat: net,
|
||||
}
|
||||
if !s.Valid() {
|
||||
// Unreachable by construction; a panic here beats a silent imbalance
|
||||
// that would be discovered later as missing money.
|
||||
panic(fmt.Sprintf("fees: split does not balance: %+v", s))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mulDivFloor computes v * num / den without overflowing int64.
|
||||
//
|
||||
// The direct form overflows for large payouts: a gross of 2.3e18 times a rake
|
||||
// of 10000 basis points is 2.3e22, far past int64. Splitting the value into
|
||||
// whole and remainder parts keeps every intermediate inside the range while
|
||||
// producing the identical floored result.
|
||||
func mulDivFloor(v, num, den int64) int64 {
|
||||
if den == 0 {
|
||||
return 0
|
||||
}
|
||||
return (v/den)*num + (v%den)*num/den
|
||||
}
|
||||
|
||||
// EffectiveRTPBasisPoints is the real return to player, given a game's own RTP
|
||||
// before fees.
|
||||
//
|
||||
// This is the number that belongs on the disclosure page. A game whose maths
|
||||
// return 9900 and whose operator takes a 1% rake does not return 99%: winners
|
||||
// hand back a percentage of what they win, so the realised return is lower.
|
||||
//
|
||||
// The rake applies only to payouts, so it scales the returned portion:
|
||||
//
|
||||
// effective = gameRTP * (1 - rake)
|
||||
//
|
||||
// Rounding is excluded here because its size depends on the payout amounts a
|
||||
// player actually hits, and overstating it would be its own dishonesty. It is
|
||||
// disclosed separately, in units, which is a claim that can be checked.
|
||||
func (sch Schedule) EffectiveRTPBasisPoints(gameRTPBasisPoints int64) int64 {
|
||||
return mulDivFloor(gameRTPBasisPoints, 10000-sch.RakeBP, 10000)
|
||||
}
|
||||
|
||||
// Disclosure is the machine-readable statement of what the operator takes.
|
||||
// The public page is rendered from this, so the published terms are generated
|
||||
// from the same values the code charges.
|
||||
type Disclosure struct {
|
||||
RakePercent string `json:"rake_percent"`
|
||||
RoundingUnit string `json:"rounding_unit"`
|
||||
MinPayout string `json:"minimum_payout"`
|
||||
GameRTPPercent string `json:"game_rtp_percent"`
|
||||
EffectiveRTP string `json:"effective_rtp_percent"`
|
||||
WorstCaseRounding string `json:"worst_case_rounding_per_payout"`
|
||||
}
|
||||
|
||||
// Describe renders the schedule for publication.
|
||||
func (sch Schedule) Describe(gameRTPBasisPoints int64) Disclosure {
|
||||
unit := sch.RoundToMsat
|
||||
if unit < 1 {
|
||||
unit = 1
|
||||
}
|
||||
return Disclosure{
|
||||
RakePercent: fmt.Sprintf("%.2f%%", float64(sch.RakeBP)/100),
|
||||
RoundingUnit: fmt.Sprintf("%d msat (%.3f sats)", unit, float64(unit)/1000),
|
||||
MinPayout: fmt.Sprintf("%d msat", sch.MinPayoutMsat),
|
||||
GameRTPPercent: fmt.Sprintf("%.2f%%",
|
||||
float64(gameRTPBasisPoints)/100),
|
||||
EffectiveRTP: fmt.Sprintf("%.2f%%",
|
||||
float64(sch.EffectiveRTPBasisPoints(gameRTPBasisPoints))/100),
|
||||
// The most a single payout can lose to rounding is one unit less one.
|
||||
WorstCaseRounding: fmt.Sprintf("%d msat (%.3f sats)",
|
||||
unit-1, float64(unit-1)/1000),
|
||||
}
|
||||
}
|
||||
195
pkg/fees/fees_test.go
Normal file
195
pkg/fees/fees_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package fees_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||
)
|
||||
|
||||
// The property that matters most: every millisatoshi is accounted for. If a
|
||||
// split ever failed to balance, the ledger's conservation check would fail and
|
||||
// the arcade would be reporting corrupt books.
|
||||
func TestEveryMillisatoshiIsAccountedFor(t *testing.T) {
|
||||
schedules := []fees.Schedule{
|
||||
fees.DefaultSchedule(),
|
||||
fees.NoFees(),
|
||||
{RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000},
|
||||
{RakeBP: 1, RoundToMsat: 1, MinPayoutMsat: 0},
|
||||
{RakeBP: 10000, RoundToMsat: 1_000, MinPayoutMsat: 0}, // 100% rake
|
||||
}
|
||||
amounts := []int64{0, 1, 999, 1_000, 1_001, 12_345, 1_000_000,
|
||||
999_999_999, math.MaxInt64 / 4}
|
||||
|
||||
for _, sch := range schedules {
|
||||
for _, gross := range amounts {
|
||||
s := sch.Apply(gross)
|
||||
if !s.Valid() {
|
||||
t.Fatalf("schedule %+v on %d produced an unbalanced split: %+v",
|
||||
sch, gross, s)
|
||||
}
|
||||
if s.NetMsat+s.HouseMsat() != gross {
|
||||
t.Fatalf("schedule %+v on %d: net %d + house %d != %d",
|
||||
sch, gross, s.NetMsat, s.HouseMsat(), gross)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakeIsExactPercentage(t *testing.T) {
|
||||
sch := fees.Schedule{RakeBP: 100, RoundToMsat: 1, MinPayoutMsat: 0}
|
||||
s := sch.Apply(1_000_000)
|
||||
if s.RakeMsat != 10_000 {
|
||||
t.Fatalf("1%% of 1000000 = %d, want 10000", s.RakeMsat)
|
||||
}
|
||||
if s.NetMsat != 990_000 {
|
||||
t.Fatalf("net = %d, want 990000", s.NetMsat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundingFloorsToTheUnit(t *testing.T) {
|
||||
sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 0}
|
||||
cases := []struct {
|
||||
gross, net, rounding int64
|
||||
}{
|
||||
{1_000, 1_000, 0},
|
||||
{1_999, 1_000, 999},
|
||||
{2_000, 2_000, 0},
|
||||
{999, 0, 999},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := sch.Apply(c.gross)
|
||||
if s.NetMsat != c.net || s.RoundingMsat != c.rounding {
|
||||
t.Errorf("gross %d: net %d rounding %d, want net %d rounding %d",
|
||||
c.gross, s.NetMsat, s.RoundingMsat, c.net, c.rounding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rounding must never take more than one unit less one from a payout. That
|
||||
// bound is what makes the disclosure checkable.
|
||||
func TestRoundingIsBoundedByOneUnit(t *testing.T) {
|
||||
sch := fees.DefaultSchedule()
|
||||
for gross := int64(1_000); gross < 200_000; gross += 37 {
|
||||
s := sch.Apply(gross)
|
||||
if s.NetMsat > 0 && s.RoundingMsat >= sch.RoundToMsat {
|
||||
t.Fatalf("gross %d lost %d msat to rounding, more than one unit (%d)",
|
||||
gross, s.RoundingMsat, sch.RoundToMsat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFeesTakesNothing(t *testing.T) {
|
||||
sch := fees.NoFees()
|
||||
for _, gross := range []int64{1, 999, 1_000, 123_456} {
|
||||
s := sch.Apply(gross)
|
||||
if s.NetMsat != gross {
|
||||
t.Fatalf("gross %d returned %d with fees disabled", gross, s.NetMsat)
|
||||
}
|
||||
if s.HouseMsat() != 0 {
|
||||
t.Fatalf("house took %d with fees disabled", s.HouseMsat())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroAndNegativeGrossAreNoOps(t *testing.T) {
|
||||
sch := fees.DefaultSchedule()
|
||||
for _, gross := range []int64{0, -1, -100_000} {
|
||||
s := sch.Apply(gross)
|
||||
if s.NetMsat != 0 || s.HouseMsat() != 0 {
|
||||
t.Fatalf("gross %d produced %+v", gross, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The published effective RTP must match what players actually receive over a
|
||||
// long run. This is the claim the disclosure page makes, so it gets checked
|
||||
// against simulated play rather than trusted.
|
||||
func TestPublishedEffectiveRTPMatchesReality(t *testing.T) {
|
||||
sch := fees.DefaultSchedule()
|
||||
const gameRTP = 9900 // the games' own 99%
|
||||
|
||||
published := sch.EffectiveRTPBasisPoints(gameRTP)
|
||||
|
||||
// Simulate: players stake, the games return 99% of stakes as gross
|
||||
// winnings, and the rake applies to those winnings.
|
||||
const rounds = 200_000
|
||||
const stake = int64(100_000) // 100 sats, large enough that rounding is noise
|
||||
var staked, received int64
|
||||
for i := 0; i < rounds; i++ {
|
||||
staked += stake
|
||||
gross := stake * gameRTP / 10000
|
||||
received += sch.Apply(gross).NetMsat
|
||||
}
|
||||
observed := received * 10000 / staked
|
||||
|
||||
if observed < published-20 || observed > published+20 {
|
||||
t.Fatalf("published effective RTP %d bp, players actually received %d bp",
|
||||
published, observed)
|
||||
}
|
||||
}
|
||||
|
||||
// A rake must reduce the advertised return. Publishing the game's own RTP
|
||||
// while taking a cut would be a false claim.
|
||||
func TestRakeReducesTheAdvertisedReturn(t *testing.T) {
|
||||
const gameRTP = 9900
|
||||
withFees := fees.DefaultSchedule().EffectiveRTPBasisPoints(gameRTP)
|
||||
withoutFees := fees.NoFees().EffectiveRTPBasisPoints(gameRTP)
|
||||
|
||||
if withoutFees != gameRTP {
|
||||
t.Fatalf("with no fees the effective RTP should equal the game RTP, got %d", withoutFees)
|
||||
}
|
||||
if withFees >= gameRTP {
|
||||
t.Fatalf("effective RTP %d is not below the game's %d despite a rake",
|
||||
withFees, gameRTP)
|
||||
}
|
||||
}
|
||||
|
||||
// Small payouts must not be silently swallowed: whatever is suppressed has to
|
||||
// show up on the house side of the split.
|
||||
func TestSuppressedPayoutIsStillAccounted(t *testing.T) {
|
||||
sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 10_000}
|
||||
s := sch.Apply(5_000)
|
||||
if s.NetMsat != 0 {
|
||||
t.Fatalf("net = %d, want 0 below the minimum", s.NetMsat)
|
||||
}
|
||||
if s.HouseMsat() != 5_000 {
|
||||
t.Fatalf("house = %d, want the full 5000 that was suppressed", s.HouseMsat())
|
||||
}
|
||||
if !s.Valid() {
|
||||
t.Fatal("suppressed payout produced an unbalanced split")
|
||||
}
|
||||
}
|
||||
|
||||
// The disclosure must be generated from the same values that are charged, so
|
||||
// the published terms cannot drift from the code.
|
||||
func TestDisclosureReflectsTheSchedule(t *testing.T) {
|
||||
sch := fees.Schedule{RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000}
|
||||
d := sch.Describe(9900)
|
||||
|
||||
if d.RakePercent != "2.50%" {
|
||||
t.Errorf("rake disclosed as %q, want 2.50%%", d.RakePercent)
|
||||
}
|
||||
// 99% game RTP with a 2.5% rake leaves 96.52%.
|
||||
if d.EffectiveRTP != "96.52%" {
|
||||
t.Errorf("effective RTP disclosed as %q, want 96.52%%", d.EffectiveRTP)
|
||||
}
|
||||
if d.WorstCaseRounding != "999 msat (0.999 sats)" {
|
||||
t.Errorf("worst-case rounding disclosed as %q", d.WorstCaseRounding)
|
||||
}
|
||||
}
|
||||
|
||||
// Extreme configurations must not overflow or produce nonsense.
|
||||
func TestExtremeSchedulesAreSafe(t *testing.T) {
|
||||
huge := int64(math.MaxInt64 / 2)
|
||||
for _, sch := range []fees.Schedule{
|
||||
{RakeBP: 10000, RoundToMsat: 1, MinPayoutMsat: 0},
|
||||
{RakeBP: 0, RoundToMsat: huge, MinPayoutMsat: 0},
|
||||
{RakeBP: 0, RoundToMsat: 0, MinPayoutMsat: 0}, // unit floor of 1
|
||||
} {
|
||||
s := sch.Apply(1_000_000)
|
||||
if !s.Valid() {
|
||||
t.Fatalf("schedule %+v produced %+v", sch, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
pkg/lightning/fake.go
Normal file
173
pkg/lightning/fake.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FakeNode is a Lightning node that can be told to misbehave.
|
||||
//
|
||||
// Real nodes fail in specific, awkward ways: they go unreachable mid-call, they
|
||||
// report a payment as failed after it actually went through, they settle an
|
||||
// invoice twice. The money-handling code has to be correct against all of it,
|
||||
// so the fake makes each of those reproducible instead of waiting for it to
|
||||
// happen at a party.
|
||||
type FakeNode struct {
|
||||
mu sync.Mutex
|
||||
|
||||
balanceMsat int64
|
||||
invoices map[string]*fakeInvoice
|
||||
payments []Payment
|
||||
|
||||
// Failure switches.
|
||||
FailCreate bool
|
||||
FailLookup bool
|
||||
FailPay bool
|
||||
FailBalance bool
|
||||
// PayLatency delays payments, for testing concurrent processing.
|
||||
PayLatency time.Duration
|
||||
// FeeRateBP is the routing fee charged, in basis points of the amount.
|
||||
// Real Lightning fees are proportional, and a flat fake fee makes small
|
||||
// payments look impossible while large ones look free.
|
||||
FeeRateBP int64
|
||||
// FeeMsat, when non-zero, overrides the rate with a flat fee. Used to
|
||||
// test the fee cap.
|
||||
FeeMsat int64
|
||||
}
|
||||
|
||||
// maxRateBP mirrors Limits.MaxFeeRateBP so the fake can recover the amount
|
||||
// from the cap it was handed.
|
||||
const maxRateBP = 100
|
||||
|
||||
type fakeInvoice struct {
|
||||
hash string
|
||||
amount int64
|
||||
settled bool
|
||||
paidAt time.Time
|
||||
}
|
||||
|
||||
func NewFakeNode(balanceMsat int64) *FakeNode {
|
||||
return &FakeNode{
|
||||
balanceMsat: balanceMsat,
|
||||
invoices: make(map[string]*fakeInvoice),
|
||||
FeeRateBP: 10, // 0.1%, a realistic routing fee
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailCreate {
|
||||
return Invoice{}, fmt.Errorf("fake: node refused to create an invoice")
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
hash := hex.EncodeToString(raw[:])
|
||||
f.invoices[hash] = &fakeInvoice{hash: hash, amount: amountMsat}
|
||||
return Invoice{
|
||||
PaymentHash: hash,
|
||||
Bolt11: "lnbcrt" + hash,
|
||||
AmountMsat: amountMsat,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailLookup {
|
||||
return false, 0, fmt.Errorf("fake: node unreachable")
|
||||
}
|
||||
inv, ok := f.invoices[paymentHash]
|
||||
if !ok {
|
||||
return false, 0, fmt.Errorf("fake: unknown invoice")
|
||||
}
|
||||
return inv.settled, inv.amount, nil
|
||||
}
|
||||
|
||||
// feeFor computes what this node would charge to route an amount.
|
||||
func (f *FakeNode) feeFor(amountMsat int64) int64 {
|
||||
if f.FeeMsat > 0 {
|
||||
return f.FeeMsat
|
||||
}
|
||||
return amountMsat * f.FeeRateBP / 10000
|
||||
}
|
||||
|
||||
func (f *FakeNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||
if f.PayLatency > 0 {
|
||||
select {
|
||||
case <-time.After(f.PayLatency):
|
||||
case <-ctx.Done():
|
||||
return Payment{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailPay {
|
||||
return Payment{}, fmt.Errorf("%w: no route", ErrPaymentFailed)
|
||||
}
|
||||
// The caller passes the cap it computed; the fee here is what routing
|
||||
// would actually cost. A real node refuses when the cap is too tight.
|
||||
fee := f.feeFor(maxFeeMsat * 10000 / maxRateBP)
|
||||
if f.FeeMsat > 0 {
|
||||
fee = f.FeeMsat
|
||||
}
|
||||
if fee > maxFeeMsat {
|
||||
return Payment{}, fmt.Errorf("%w: fee %d exceeds cap %d",
|
||||
ErrPaymentFailed, fee, maxFeeMsat)
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Payment{}, err
|
||||
}
|
||||
p := Payment{
|
||||
PaymentHash: hex.EncodeToString(raw[:]),
|
||||
Preimage: hex.EncodeToString(raw[:]),
|
||||
FeeMsat: fee,
|
||||
}
|
||||
f.payments = append(f.payments, p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (f *FakeNode) Balance(ctx context.Context) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailBalance {
|
||||
return 0, fmt.Errorf("fake: node unreachable")
|
||||
}
|
||||
return f.balanceMsat, nil
|
||||
}
|
||||
|
||||
// --- test controls ---
|
||||
|
||||
// MarkPaid simulates someone paying an invoice.
|
||||
func (f *FakeNode) MarkPaid(paymentHash string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if inv, ok := f.invoices[paymentHash]; ok {
|
||||
inv.settled = true
|
||||
inv.paidAt = time.Now()
|
||||
f.balanceMsat += inv.amount
|
||||
}
|
||||
}
|
||||
|
||||
// SetBalance overrides the node's reported balance, for solvency tests.
|
||||
func (f *FakeNode) SetBalance(msat int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.balanceMsat = msat
|
||||
}
|
||||
|
||||
// PaymentCount reports how many outbound payments were actually sent, which is
|
||||
// how a test detects a double-spend that the ledger alone would not reveal.
|
||||
func (f *FakeNode) PaymentCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.payments)
|
||||
}
|
||||
358
pkg/lightning/lightning.go
Normal file
358
pkg/lightning/lightning.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Package lightning moves real money in and out of the internal ledger.
|
||||
//
|
||||
// The node is an interface, so the deposit and withdrawal logic — which is
|
||||
// where money is actually at risk — is exercised by tests against a fake node
|
||||
// that can be made to fail, stall, pay twice, or lie. Swapping in a real node
|
||||
// is configuration, not new code.
|
||||
//
|
||||
// Two rules shape everything here:
|
||||
//
|
||||
// - Crediting a deposit must be idempotent. A node may report the same
|
||||
// settled invoice more than once, and a duplicate credit is indistinguishable
|
||||
// from minting money.
|
||||
// - A withdrawal must debit before it pays. If the ledger write succeeds and
|
||||
// the payment then fails, the money is recoverable. If the payment succeeds
|
||||
// and the ledger write fails, it is not.
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNodeUnavailable = errors.New("lightning: node unavailable")
|
||||
ErrPaymentFailed = errors.New("lightning: payment failed")
|
||||
ErrAmountOutOfRange = errors.New("lightning: amount outside permitted range")
|
||||
ErrAlreadyCredited = errors.New("lightning: invoice already credited")
|
||||
ErrNeedsApproval = errors.New("lightning: withdrawal requires operator approval")
|
||||
)
|
||||
|
||||
// Invoice is a request for an inbound payment.
|
||||
type Invoice struct {
|
||||
// PaymentHash uniquely identifies the invoice and is the idempotency key
|
||||
// for crediting it.
|
||||
PaymentHash string
|
||||
Bolt11 string
|
||||
AmountMsat int64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Payment is the result of an outbound send.
|
||||
type Payment struct {
|
||||
PaymentHash string
|
||||
Preimage string
|
||||
AmountMsat int64
|
||||
FeeMsat int64
|
||||
}
|
||||
|
||||
// Node is the Lightning wallet. Implemented by the Alby Hub client in
|
||||
// production and by a fake in tests.
|
||||
type Node interface {
|
||||
// CreateInvoice requests an inbound payment.
|
||||
CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error)
|
||||
|
||||
// LookupInvoice reports whether an invoice has been paid.
|
||||
LookupInvoice(ctx context.Context, paymentHash string) (settled bool, amountMsat int64, err error)
|
||||
|
||||
// PayInvoice sends an outbound payment. maxFeeMsat bounds the routing fee.
|
||||
PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error)
|
||||
|
||||
// Balance reports spendable millisatoshis held by the node.
|
||||
Balance(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// Limits bound what the service will do without a human.
|
||||
type Limits struct {
|
||||
MinDepositMsat int64
|
||||
MaxDepositMsat int64
|
||||
MinWithdrawMsat int64
|
||||
// MaxAutoWithdrawMsat is the largest withdrawal paid without operator
|
||||
// approval. Above it the request is queued. This is the blast radius of a
|
||||
// stolen session token.
|
||||
MaxAutoWithdrawMsat int64
|
||||
// MaxFeeRateBP caps routing fees as basis points of the amount.
|
||||
MaxFeeRateBP int64
|
||||
}
|
||||
|
||||
// DefaultLimits are deliberately conservative.
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{
|
||||
MinDepositMsat: 1_000, // 1 sat
|
||||
MaxDepositMsat: 100_000_000, // 100k sats
|
||||
MinWithdrawMsat: 1_000,
|
||||
MaxAutoWithdrawMsat: 50_000_000, // 50k sats
|
||||
MaxFeeRateBP: 100, // 1%
|
||||
}
|
||||
}
|
||||
|
||||
// Service ties the node to the ledger.
|
||||
type Service struct {
|
||||
node Node
|
||||
ledger *ledger.Ledger
|
||||
pool *pgxpool.Pool
|
||||
limits Limits
|
||||
}
|
||||
|
||||
func New(node Node, l *ledger.Ledger, pool *pgxpool.Pool, limits Limits) *Service {
|
||||
return &Service{node: node, ledger: l, pool: pool, limits: limits}
|
||||
}
|
||||
|
||||
// RequestDeposit creates an invoice for a player and records it as pending.
|
||||
func (s *Service) RequestDeposit(ctx context.Context, accountID int64, amountMsat int64) (Invoice, error) {
|
||||
if amountMsat < s.limits.MinDepositMsat || amountMsat > s.limits.MaxDepositMsat {
|
||||
return Invoice{}, fmt.Errorf("%w: deposits are %d to %d msat",
|
||||
ErrAmountOutOfRange, s.limits.MinDepositMsat, s.limits.MaxDepositMsat)
|
||||
}
|
||||
|
||||
inv, err := s.node.CreateInvoice(ctx, amountMsat,
|
||||
fmt.Sprintf("Quantum Arcade deposit for account %d", accountID))
|
||||
if err != nil {
|
||||
return Invoice{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO lightning_invoices
|
||||
(payment_hash, account_id, amount_msat, bolt11, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
inv.PaymentHash, accountID, inv.AmountMsat, inv.Bolt11, inv.ExpiresAt); err != nil {
|
||||
return Invoice{}, fmt.Errorf("recording invoice: %w", err)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// SettleDeposit credits a paid invoice to its player.
|
||||
//
|
||||
// It is idempotent by payment hash. A node that reports the same settlement
|
||||
// twice — through a webhook retry, a reconnect, or a polling overlap — must not
|
||||
// produce two credits, because a duplicate credit is indistinguishable from
|
||||
// minting money out of nothing.
|
||||
func (s *Service) SettleDeposit(ctx context.Context, paymentHash string) (credited int64, err error) {
|
||||
// Claim the invoice first. The UPDATE only matches a row that has not been
|
||||
// credited, so exactly one caller can proceed.
|
||||
var accountID, amountMsat int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`UPDATE lightning_invoices
|
||||
SET credited_at = now()
|
||||
WHERE payment_hash = $1 AND credited_at IS NULL
|
||||
RETURNING account_id, amount_msat`,
|
||||
paymentHash).Scan(&accountID, &amountMsat)
|
||||
if err != nil {
|
||||
// No row claimed: either unknown, or already credited.
|
||||
var exists bool
|
||||
if e := s.pool.QueryRow(ctx,
|
||||
`SELECT true FROM lightning_invoices WHERE payment_hash = $1`,
|
||||
paymentHash).Scan(&exists); e == nil && exists {
|
||||
return 0, ErrAlreadyCredited
|
||||
}
|
||||
return 0, fmt.Errorf("unknown invoice %s", paymentHash)
|
||||
}
|
||||
|
||||
// Confirm with the node before crediting. Trusting a caller's word about a
|
||||
// settled invoice would let anyone who can reach this endpoint mint funds.
|
||||
settled, paidMsat, err := s.node.LookupInvoice(ctx, paymentHash)
|
||||
if err != nil {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
if !settled {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("invoice %s is not settled", paymentHash)
|
||||
}
|
||||
// Credit what actually arrived, not what was asked for.
|
||||
if paidMsat > 0 && paidMsat != amountMsat {
|
||||
amountMsat = paidMsat
|
||||
}
|
||||
|
||||
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("crediting ledger: %w", err)
|
||||
}
|
||||
return amountMsat, nil
|
||||
}
|
||||
|
||||
// releaseClaim undoes a claim when the credit could not be completed, so a
|
||||
// transient failure does not strand a real payment forever.
|
||||
func (s *Service) releaseClaim(ctx context.Context, paymentHash string) {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_invoices SET credited_at = NULL WHERE payment_hash = $1`,
|
||||
paymentHash); err != nil {
|
||||
fmt.Printf("lightning: could not release claim on %s: %v\n", paymentHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestWithdrawal debits a player and queues an outbound payment.
|
||||
//
|
||||
// The debit happens first and in the same call. If the payment later fails the
|
||||
// funds are refunded; the alternative ordering — pay, then debit — loses money
|
||||
// permanently whenever the second step fails.
|
||||
func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (int64, error) {
|
||||
if amountMsat < s.limits.MinWithdrawMsat {
|
||||
return 0, fmt.Errorf("%w: minimum withdrawal is %d msat",
|
||||
ErrAmountOutOfRange, s.limits.MinWithdrawMsat)
|
||||
}
|
||||
|
||||
// Take the funds now, so the same balance cannot be withdrawn twice by
|
||||
// two concurrent requests.
|
||||
if _, err := s.ledger.Withdraw(ctx, accountID, amountMsat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
status := "queued"
|
||||
if amountMsat > s.limits.MaxAutoWithdrawMsat {
|
||||
// Large withdrawals wait for a human. This bounds what a stolen
|
||||
// session token can remove.
|
||||
status = "needs_approval"
|
||||
}
|
||||
|
||||
var id int64
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO lightning_withdrawals
|
||||
(account_id, bolt11, amount_msat, status)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
accountID, bolt11, amountMsat, status).Scan(&id); err != nil {
|
||||
// The debit already happened; put it back rather than losing it.
|
||||
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||
fmt.Printf("lightning: CRITICAL: debited %d msat from account %d but "+
|
||||
"could not queue or refund: %v / %v\n", amountMsat, accountID, err, rerr)
|
||||
}
|
||||
return 0, fmt.Errorf("queueing withdrawal: %w", err)
|
||||
}
|
||||
if status == "needs_approval" {
|
||||
return id, ErrNeedsApproval
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
||||
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, account_id, bolt11, amount_msat
|
||||
FROM lightning_withdrawals
|
||||
WHERE status = 'queued'
|
||||
ORDER BY id
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type job struct {
|
||||
id, account, amount int64
|
||||
bolt11 string
|
||||
}
|
||||
var jobs []job
|
||||
for rows.Next() {
|
||||
var j job
|
||||
if err := rows.Scan(&j.id, &j.account, &j.bolt11, &j.amount); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
paid := 0
|
||||
for _, j := range jobs {
|
||||
// Claim before paying, so two instances cannot send the same payment.
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals SET status = 'sending'
|
||||
WHERE id = $1 AND status = 'queued'`, j.id)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
maxFee := j.amount * s.limits.MaxFeeRateBP / 10000
|
||||
payment, err := s.node.PayInvoice(ctx, j.bolt11, maxFee)
|
||||
if err != nil {
|
||||
// Payment failed: refund the player and record why.
|
||||
if _, rerr := s.ledger.Deposit(ctx, j.account, j.amount); rerr != nil {
|
||||
fmt.Printf("lightning: CRITICAL: payment %d failed and refund failed: %v\n",
|
||||
j.id, rerr)
|
||||
}
|
||||
if _, uerr := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'failed', failure = $2, resolved_at = now()
|
||||
WHERE id = $1`, j.id, err.Error()); uerr != nil {
|
||||
fmt.Printf("lightning: recording failure for %d: %v\n", j.id, uerr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'paid', payment_hash = $2, fee_msat = $3, resolved_at = now()
|
||||
WHERE id = $1`, j.id, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||
fmt.Printf("lightning: payment %d sent but not recorded: %v\n", j.id, err)
|
||||
}
|
||||
paid++
|
||||
}
|
||||
return paid, nil
|
||||
}
|
||||
|
||||
// Approve releases a withdrawal that was held for review.
|
||||
func (s *Service) Approve(ctx context.Context, withdrawalID int64) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals SET status = 'queued'
|
||||
WHERE id = $1 AND status = 'needs_approval'`, withdrawalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject cancels a held withdrawal and refunds the player.
|
||||
func (s *Service) Reject(ctx context.Context, withdrawalID int64, reason string) error {
|
||||
var accountID, amountMsat int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'rejected', failure = $2, resolved_at = now()
|
||||
WHERE id = $1 AND status = 'needs_approval'
|
||||
RETURNING account_id, amount_msat`,
|
||||
withdrawalID, reason).Scan(&accountID, &amountMsat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||
}
|
||||
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||
return fmt.Errorf("refunding rejected withdrawal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Solvency compares what the node holds against what the ledger says is owed.
|
||||
//
|
||||
// These must agree. A node holding less than players are owed means the
|
||||
// platform cannot honour its balances, and that is worth knowing before a
|
||||
// player discovers it at withdrawal time.
|
||||
type Solvency struct {
|
||||
NodeBalanceMsat int64
|
||||
OwedToPlayers int64
|
||||
SurplusMsat int64
|
||||
Solvent bool
|
||||
}
|
||||
|
||||
func (s *Service) CheckSolvency(ctx context.Context) (Solvency, error) {
|
||||
nodeBal, err := s.node.Balance(ctx)
|
||||
if err != nil {
|
||||
return Solvency{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
owed, err := s.ledger.TotalIssued(ctx)
|
||||
if err != nil {
|
||||
return Solvency{}, err
|
||||
}
|
||||
return Solvency{
|
||||
NodeBalanceMsat: nodeBal,
|
||||
OwedToPlayers: owed,
|
||||
SurplusMsat: nodeBal - owed,
|
||||
Solvent: nodeBal >= owed,
|
||||
}, nil
|
||||
}
|
||||
486
pkg/lightning/lightning_test.go
Normal file
486
pkg/lightning/lightning_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package lightning_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database available: %v", err)
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Skipf("no database available: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
svc *lightning.Service
|
||||
node *lightning.FakeNode
|
||||
ledger *ledger.Ledger
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
l := ledger.New(pool)
|
||||
node := lightning.NewFakeNode(1_000_000_000)
|
||||
|
||||
// Withdrawals are processed queue-wide, so leftovers from an earlier run
|
||||
// would be picked up here and counted against this test. Park them.
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE lightning_withdrawals SET status = 'rejected',
|
||||
failure = 'cleared by test fixture', resolved_at = now()
|
||||
WHERE status IN ('queued', 'sending')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fixture{
|
||||
t: t, node: node, ledger: l, ctx: context.Background(),
|
||||
svc: lightning.New(node, l, pool, lightning.DefaultLimits()),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) player(label string, fundMsat int64) int64 {
|
||||
f.t.Helper()
|
||||
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
||||
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if fundMsat > 0 {
|
||||
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/* ---------------- deposits ---------------- */
|
||||
|
||||
func TestDepositCreditsOnlyAfterPayment(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, err := f.svc.RequestDeposit(f.ctx, id, 50_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nobody has paid yet: settling must refuse.
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err == nil {
|
||||
t.Fatal("an unpaid invoice was credited")
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d before payment, want 0", bal)
|
||||
}
|
||||
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
credited, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credited != 50_000 {
|
||||
t.Fatalf("credited %d, want 50000", credited)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 50_000 {
|
||||
t.Fatalf("balance = %d after payment, want 50000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// A node reporting the same settlement twice must not mint money.
|
||||
func TestDepositIsIdempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 25_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrAlreadyCredited) {
|
||||
t.Fatalf("repeat settle %d gave %v, want ErrAlreadyCredited", i, err)
|
||||
}
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 25_000 {
|
||||
t.Fatalf("balance = %d after repeated settlement, want 25000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent settlements of one invoice — a webhook and a poll racing — must
|
||||
// credit exactly once.
|
||||
func TestConcurrentSettlementCreditsOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 30_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
succeeded := make([]bool, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||
succeeded[i] = err == nil
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins := 0
|
||||
for _, ok := range succeeded {
|
||||
if ok {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 30_000 {
|
||||
t.Fatalf("balance = %d, want 30000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller cannot conjure a credit by naming an invoice the node knows nothing
|
||||
// about.
|
||||
func TestUnknownInvoiceCannotBeCredited(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, "deadbeef"); err == nil {
|
||||
t.Fatal("an unknown invoice was credited")
|
||||
}
|
||||
}
|
||||
|
||||
// If the node is unreachable at settle time, the claim must be released so the
|
||||
// real payment is not stranded forever.
|
||||
func TestFailedLookupReleasesTheClaim(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 10_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
f.node.FailLookup = true
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrNodeUnavailable) {
|
||||
t.Fatalf("got %v, want ErrNodeUnavailable", err)
|
||||
}
|
||||
|
||||
// Once the node returns, the deposit must still be creditable.
|
||||
f.node.FailLookup = false
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatalf("deposit stranded after a transient failure: %v", err)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||
t.Fatalf("balance = %d, want 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepositLimitsEnforced(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
limits := lightning.DefaultLimits()
|
||||
|
||||
for _, amt := range []int64{0, limits.MinDepositMsat - 1, limits.MaxDepositMsat + 1} {
|
||||
if _, err := f.svc.RequestDeposit(f.ctx, id, amt); !errors.Is(err, lightning.ErrAmountOutOfRange) {
|
||||
t.Errorf("amount %d gave %v, want ErrAmountOutOfRange", amt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- withdrawals ---------------- */
|
||||
|
||||
func TestWithdrawalDebitsImmediately(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc-invoice", 40_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Debited at request time, not at send time: otherwise the same balance
|
||||
// could be withdrawn twice while the first payment is in flight.
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 60_000 {
|
||||
t.Fatalf("balance = %d after requesting withdrawal, want 60000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotWithdrawMoreThanBalance(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 10_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000); !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||
t.Fatalf("balance = %d after a refused withdrawal, want 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Two concurrent withdrawals of the same funds: exactly one may proceed.
|
||||
func TestConcurrentWithdrawalsCannotDoubleSpend(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 50_000)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, results[i] = f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
ok := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
if ok != 1 {
|
||||
t.Fatalf("%d concurrent withdrawals of the same balance succeeded, want 1", ok)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d, want 0", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulWithdrawalIsPaidOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("node sent %d payments, want 1", got)
|
||||
}
|
||||
// Processing again must not re-send.
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("reprocessing sent the payment again: %d total", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed payment must return the money.
|
||||
func TestFailedPaymentRefundsThePlayer(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 30_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterRequest, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
f.node.FailPay = true
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != afterRequest+30_000 {
|
||||
t.Fatalf("balance = %d after a failed payment, want %d (refunded)",
|
||||
after, afterRequest+30_000)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent processors, as two instances would be, must not double-send.
|
||||
func TestConcurrentProcessorsSendOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 500_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.node.PayLatency = 150 * time.Millisecond
|
||||
before := f.node.PaymentCount()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = f.svc.ProcessWithdrawals(f.ctx, 10)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("concurrent processors sent %d payments, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Large withdrawals wait for a human. This bounds what a stolen token removes.
|
||||
func TestLargeWithdrawalNeedsApproval(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
limits := lightning.DefaultLimits()
|
||||
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||
|
||||
amount := limits.MaxAutoWithdrawMsat + 1
|
||||
wid, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||
if !errors.Is(err, lightning.ErrNeedsApproval) {
|
||||
t.Fatalf("got %v, want ErrNeedsApproval", err)
|
||||
}
|
||||
|
||||
// It must not be paid while it waits.
|
||||
before := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != before {
|
||||
t.Fatal("a withdrawal awaiting approval was paid")
|
||||
}
|
||||
|
||||
if err := f.svc.Approve(f.ctx, wid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != before+1 {
|
||||
t.Fatal("an approved withdrawal was not paid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedWithdrawalIsRefunded(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
limits := lightning.DefaultLimits()
|
||||
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
amount := limits.MaxAutoWithdrawMsat + 1
|
||||
wid, _ := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||
|
||||
if err := f.svc.Reject(f.ctx, wid, "suspicious"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != before {
|
||||
t.Fatalf("balance = %d after rejection, want %d (fully refunded)", after, before)
|
||||
}
|
||||
// A rejected withdrawal must never be paid afterwards.
|
||||
count := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != count {
|
||||
t.Fatal("a rejected withdrawal was paid")
|
||||
}
|
||||
}
|
||||
|
||||
// A routing fee above the cap must fail rather than quietly cost the house.
|
||||
func TestExcessiveFeeIsRefused(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 1_000_000)
|
||||
|
||||
f.node.FeeMsat = 500_000 // far above 1% of the amount
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != before+100_000 {
|
||||
t.Fatalf("balance = %d, want %d — an over-priced payment should refund",
|
||||
after, before+100_000)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- solvency ---------------- */
|
||||
|
||||
func TestSolvencyDetectsShortfall(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
owed, err := f.ledger.TotalIssued(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.node.SetBalance(owed + 1_000_000)
|
||||
s, err := f.svc.CheckSolvency(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Solvent {
|
||||
t.Fatalf("reported insolvent while holding a surplus: %+v", s)
|
||||
}
|
||||
|
||||
// Now the node holds less than players are owed.
|
||||
f.node.SetBalance(owed - 1)
|
||||
s, err = f.svc.CheckSolvency(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Solvent {
|
||||
t.Fatalf("reported solvent while short: %+v", s)
|
||||
}
|
||||
if s.SurplusMsat >= 0 {
|
||||
t.Fatalf("surplus = %d, want negative", s.SurplusMsat)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- round trip ---------------- */
|
||||
|
||||
// Money in, play, money out — and the books balance at the end.
|
||||
func TestFullDepositWithdrawRoundTrip(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, err := f.svc.RequestDeposit(f.ctx, id, 80_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 80_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d after a full round trip, want 0", bal)
|
||||
}
|
||||
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("books do not balance after a round trip: %d", total)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
// 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 {
|
||||
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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user