Compare commits
10 Commits
3bdb518f9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fa097eab | ||
|
|
2eafcbfd68 | ||
|
|
c250ed2f80 | ||
|
|
bf75302e07 | ||
|
|
b0f07f63ff | ||
|
|
8af6fd585e | ||
|
|
ca39e8bad9 | ||
|
|
1da3b6760e | ||
|
|
e70258c54d | ||
|
|
f097721304 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
bin/
|
||||
*.log
|
||||
coverage.out
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -483,12 +484,36 @@ func TestFullPlayerJourney(t *testing.T) {
|
||||
crash := float64(*proof.CrashPoint) / 4294967296.0
|
||||
|
||||
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
||||
// reached the target, nothing otherwise.
|
||||
// reached the target, nothing otherwise — net of the disclosed fees.
|
||||
var after struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
|
||||
// Derive the expected payout from the published fee schedule rather than
|
||||
// hardcoding it. A test that hardcodes the net would silently stop
|
||||
// checking anything the moment the operator changed the rake.
|
||||
var sched struct {
|
||||
Schedule struct {
|
||||
RakePercent string `json:"rake_percent"`
|
||||
RoundingUnit string `json:"rounding_unit"`
|
||||
} `json:"schedule"`
|
||||
}
|
||||
alice.do("GET", "/api/fees", nil, &sched)
|
||||
|
||||
var rakePct float64
|
||||
fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
|
||||
var roundUnit int64
|
||||
fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
|
||||
if roundUnit < 1 {
|
||||
roundUnit = 1
|
||||
}
|
||||
|
||||
gross := int64(stake) * 3 / 2
|
||||
rake := int64(float64(gross) * rakePct / 100)
|
||||
net := (gross - rake) / roundUnit * roundUnit
|
||||
|
||||
// Settlement posts a moment after the reveal; poll briefly.
|
||||
wantWin := beforeRound - stake + stake*3/2
|
||||
wantWin := beforeRound - stake + net
|
||||
wantLose := beforeRound - stake
|
||||
ok := false
|
||||
for i := 0; i < 20; i++ {
|
||||
@@ -504,8 +529,9 @@ func TestFullPlayerJourney(t *testing.T) {
|
||||
after.BalanceMsat, wantWin, wantLose)
|
||||
}
|
||||
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
||||
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d",
|
||||
crash, after.BalanceMsat, wantWin)
|
||||
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
|
||||
"(gross %d, rake %d, net %d)",
|
||||
crash, after.BalanceMsat, wantWin, gross, rake, net)
|
||||
}
|
||||
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||
|
||||
139
cmd/arcade/lnurl.go
Normal file
139
cmd/arcade/lnurl.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||
)
|
||||
|
||||
// LNURL-withdraw endpoints.
|
||||
//
|
||||
// Two of these are called by the player's wallet, not by the arcade's own
|
||||
// client, so they follow the LNURL specification's shapes rather than this
|
||||
// project's conventions: a bare JSON object with a status field, and no
|
||||
// bearer token, because a wallet has none. The single-use k1 is the
|
||||
// authorisation.
|
||||
|
||||
func (s *server) routesLNURL(mux *http.ServeMux) {
|
||||
// Called by the arcade's own client, authenticated as normal.
|
||||
mux.HandleFunc("POST /api/withdraw/code", s.handleWithdrawCode)
|
||||
|
||||
// Called by the player's wallet after scanning. No session exists here.
|
||||
mux.HandleFunc("GET /lnurl/withdraw", s.handleLNURLTerms)
|
||||
mux.HandleFunc("GET /lnurl/withdraw/callback", s.handleLNURLCallback)
|
||||
}
|
||||
|
||||
// handleWithdrawCode issues a scannable cash-out code.
|
||||
//
|
||||
// The funds are debited when the code is issued, not when it is redeemed. A
|
||||
// code is an authorisation to pull an exact amount, and leaving the balance
|
||||
// spendable while a code is outstanding would let a player cash out and then
|
||||
// bet the same sats before the wallet claims them.
|
||||
func (s *server) handleWithdrawCode(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ln == nil || s.lnurl == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||
return
|
||||
}
|
||||
accountID, _, ok := s.account(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AmountSat int64 `json:"amount_sats"`
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "could not read request")
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil || req.AmountSat < 1 {
|
||||
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
|
||||
return
|
||||
}
|
||||
amountMsat := req.AmountSat * 1000
|
||||
|
||||
// Take the money now. If the code is never scanned, the sweep below
|
||||
// returns it.
|
||||
if _, err := s.ledger.Withdraw(r.Context(), accountID, amountMsat); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
code, k1, err := s.lnurl.Issue(accountID, amountMsat)
|
||||
if err != nil {
|
||||
// Could not issue: give the money straight back rather than holding it
|
||||
// against a code that does not exist.
|
||||
if _, rerr := s.ledger.Deposit(r.Context(), accountID, amountMsat); rerr != nil {
|
||||
log.Printf("lnurl: CRITICAL: debited %d msat from %d but could not "+
|
||||
"issue a code or refund: %v / %v", amountMsat, accountID, err, rerr)
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"lnurl": code,
|
||||
"k1": k1,
|
||||
"amount_sats": req.AmountSat,
|
||||
"balance_msat": bal,
|
||||
"expires_in": int(lnurl.TokenTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleLNURLTerms is what the wallet fetches after scanning.
|
||||
func (s *server) handleLNURLTerms(w http.ResponseWriter, r *http.Request) {
|
||||
if s.lnurl == nil {
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||
return
|
||||
}
|
||||
terms, err := s.lnurl.Describe(r.URL.Query().Get("k1"))
|
||||
if err != nil {
|
||||
// LNURL errors are 200s with a status field; a wallet shows the reason
|
||||
// to the player, whereas an HTTP error shows nothing useful.
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, terms)
|
||||
}
|
||||
|
||||
// handleLNURLCallback is where the wallet delivers its invoice.
|
||||
func (s *server) handleLNURLCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ln == nil || s.lnurl == nil {
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
k1 := r.URL.Query().Get("k1")
|
||||
invoice := r.URL.Query().Get("pr")
|
||||
if k1 == "" || invoice == "" {
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail("k1 and pr are required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Consume the token before paying. A token left valid after a successful
|
||||
// payment could be replayed for the same amount again.
|
||||
token, err := s.lnurl.Redeem(r.Context(), k1)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// The balance was already debited when the code was issued, so this queues
|
||||
// the payment against funds the arcade is holding rather than the player's
|
||||
// balance. Crediting first and withdrawing again would double-charge.
|
||||
if _, err := s.ln.PayHeld(r.Context(), token.AccountID, invoice, token.AmountMsat); err != nil {
|
||||
// Payment failed: hand the authorisation back so the player can retry
|
||||
// rather than losing the cash-out silently.
|
||||
s.lnurl.Restore(token)
|
||||
log.Printf("lnurl: payment for account %d failed: %v", token.AccountID, err)
|
||||
writeJSON(w, http.StatusOK, lnurl.Fail("payment failed; try scanning again"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, lnurl.OK())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -21,11 +22,16 @@ 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/lightning"
|
||||
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||
"github.com/drjones/quantum-arcade/pkg/room"
|
||||
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -42,11 +48,14 @@ const maxAutoCashOut = 1_000_000
|
||||
var games = []string{"rocket", "orbital", "tower"}
|
||||
|
||||
type server struct {
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
auth *identity.Authenticator
|
||||
rooms map[string]*room.Room
|
||||
hubs map[string]*gameHub
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
auth *identity.Authenticator
|
||||
rooms map[string]*room.Room
|
||||
tournaments *tournament.Service
|
||||
hubs map[string]*gameHub
|
||||
ln *lightning.Service // Lightning deposit/withdrawal
|
||||
lnurl *lnurl.Service // scannable cash-out codes
|
||||
|
||||
// Sessions live in Redis rather than instance memory. With several cloned
|
||||
// instances behind one endpoint, a token issued by one must be accepted by
|
||||
@@ -105,13 +114,15 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
l := ledger.New(pool)
|
||||
s := &server{
|
||||
pool: pool,
|
||||
ledger: ledger.New(pool),
|
||||
auth: identity.NewAuthenticator(),
|
||||
rooms: make(map[string]*room.Room),
|
||||
hubs: make(map[string]*gameHub),
|
||||
rdb: rdb,
|
||||
pool: pool,
|
||||
ledger: l,
|
||||
tournaments: tournament.New(pool, l),
|
||||
auth: identity.NewAuthenticator(),
|
||||
rooms: make(map[string]*room.Room),
|
||||
hubs: make(map[string]*gameHub),
|
||||
rdb: rdb,
|
||||
}
|
||||
|
||||
// Identity is generated, not configured: a cloned VM boots with its own
|
||||
@@ -123,6 +134,74 @@ func main() {
|
||||
defer s.node.Stop(context.Background())
|
||||
log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)
|
||||
|
||||
// ── Lightning (optional: dev faucet works without it) ──
|
||||
//
|
||||
// The faucet and a real node must never both be enabled. The faucet mints
|
||||
// balance backed by nothing; with a real node attached, a player can
|
||||
// withdraw that balance as actual satoshis and drain the node. Refusing to
|
||||
// start is the only safe response — a warning would be read once and
|
||||
// forgotten, and the failure is silent until the money is gone.
|
||||
if os.Getenv("ALBY_URL") != "" && os.Getenv("ARCADE_DEV_FAUCET") == "1" {
|
||||
log.Fatal("REFUSING TO START: ARCADE_DEV_FAUCET=1 with a real Lightning node " +
|
||||
"configured. The faucet mints unbacked balance, which could then be " +
|
||||
"withdrawn as real satoshis. Unset one of them.")
|
||||
}
|
||||
|
||||
if url := os.Getenv("ALBY_URL"); url != "" {
|
||||
token := os.Getenv("ALBY_TOKEN")
|
||||
if token == "" {
|
||||
log.Printf("ALBY_URL set but ALBY_TOKEN empty — Lightning disabled")
|
||||
} else {
|
||||
albyNode := lightning.NewAlbyNode(url, token)
|
||||
limits := lightning.DefaultLimits()
|
||||
s.ln = lightning.New(albyNode, s.ledger, s.pool, limits)
|
||||
|
||||
// Wallets reach this instance directly, so the code must carry an
|
||||
// address they can actually resolve — not localhost.
|
||||
base := os.Getenv("ARCADE_PUBLIC_URL")
|
||||
if base == "" {
|
||||
base = "http://" + advertiseAddr()
|
||||
}
|
||||
s.lnurl = lnurl.NewService(base)
|
||||
log.Printf("Lightning node connected: %s (cash-out codes point at %s)", url, base)
|
||||
// Process queued withdrawals every 15 seconds.
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Check solvency before paying anything out. If the
|
||||
// node holds less than players are owed, paying the
|
||||
// front of the queue drains what is left and the
|
||||
// players behind them get nothing — the worst possible
|
||||
// order to discover a shortfall in.
|
||||
sol, err := s.ln.CheckSolvency(ctx)
|
||||
if err != nil {
|
||||
log.Printf("lightning: cannot verify solvency, holding withdrawals: %v", err)
|
||||
continue
|
||||
}
|
||||
if !sol.Solvent {
|
||||
log.Printf("lightning: HOLDING WITHDRAWALS — node holds %d msat "+
|
||||
"but players are owed %d msat (short by %d)",
|
||||
sol.NodeBalanceMsat, sol.OwedToPlayers, -sol.SurplusMsat)
|
||||
continue
|
||||
}
|
||||
if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil {
|
||||
log.Printf("lightning: withdrawal processor: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("lightning: paid %d withdrawals", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
log.Printf("ALBY_URL not set — Lightning disabled (dev faucet only)")
|
||||
}
|
||||
|
||||
// One hub per game. Each hub campaigns for leadership: the winner drives
|
||||
// the rounds and publishes frames, the rest relay those frames to their
|
||||
// own clients. Roles are renegotiated continuously, so losing an instance
|
||||
@@ -134,11 +213,54 @@ func main() {
|
||||
go h.supervise(ctx)
|
||||
}
|
||||
|
||||
// Refund cash-out codes that were issued but never scanned. The balance is
|
||||
// debited when a code is minted, so an abandoned code leaves the player
|
||||
// short until this returns it.
|
||||
if s.lnurl != nil {
|
||||
go func() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
for _, tok := range s.lnurl.Expired() {
|
||||
if _, err := s.ledger.Deposit(ctx, tok.AccountID, tok.AmountMsat); err != nil {
|
||||
log.Printf("lnurl: could not refund unscanned code for "+
|
||||
"account %d (%d msat): %v", tok.AccountID, tok.AmountMsat, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("lnurl: refunded %d msat to account %d "+
|
||||
"(cash-out code was never scanned)", tok.AmountMsat, tok.AccountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Sweep for rounds abandoned by an instance that died mid-flight and
|
||||
// refund their stakes. Every instance runs this; the claim is atomic, so
|
||||
// concurrent sweeps refund exactly once.
|
||||
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
||||
|
||||
// Move tournaments through their lifecycle by wall clock. Every instance
|
||||
// runs this; the updates are idempotent, so it needs no leader.
|
||||
go func() {
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.tournaments.AdvanceSchedules(ctx); err != nil {
|
||||
log.Printf("tournaments: advancing schedules: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.routes(),
|
||||
@@ -168,6 +290,10 @@ func (s *server) routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/balance", s.handleBalance)
|
||||
mux.HandleFunc("GET /api/history", s.handleHistory)
|
||||
mux.HandleFunc("POST /api/transfer", s.handleTransfer)
|
||||
mux.HandleFunc("POST /api/deposit", s.handleDeposit)
|
||||
mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck)
|
||||
mux.HandleFunc("POST /api/withdraw", s.handleWithdraw)
|
||||
s.routesLNURL(mux)
|
||||
mux.HandleFunc("GET /api/games", s.handleGames)
|
||||
mux.HandleFunc("POST /api/bet", s.handleBet)
|
||||
mux.HandleFunc("POST /api/cashout", s.handleCashout)
|
||||
@@ -184,6 +310,14 @@ 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)
|
||||
mux.HandleFunc("GET /api/tournaments", s.handleTournaments)
|
||||
mux.HandleFunc("GET /api/tournaments/{id}/leaderboard", s.handleLeaderboard)
|
||||
mux.HandleFunc("POST /api/tournaments/{id}/enter", s.handleEnterTournament)
|
||||
|
||||
// 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 {
|
||||
@@ -397,6 +531,96 @@ func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||
}
|
||||
|
||||
// ───────── Lightning deposit/withdrawal ─────────
|
||||
|
||||
type depositRequest struct {
|
||||
AmountSat int64 `json:"amount_sats"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeposit(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ln == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||
return
|
||||
}
|
||||
acctID, _, ok := s.account(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||
return
|
||||
}
|
||||
var req depositRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountSat < 1 {
|
||||
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
|
||||
return
|
||||
}
|
||||
inv, err := s.ln.RequestDeposit(r.Context(), acctID, req.AmountSat*1000)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"payment_hash": inv.PaymentHash,
|
||||
"invoice": inv.Bolt11,
|
||||
"amount_sats": inv.AmountMsat / 1000,
|
||||
"expires_at": inv.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
type depositCheckRequest struct {
|
||||
PaymentHash string `json:"payment_hash"`
|
||||
}
|
||||
|
||||
func (s *server) handleDepositCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ln == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||
return
|
||||
}
|
||||
var req depositCheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PaymentHash == "" {
|
||||
writeErr(w, http.StatusBadRequest, "payment_hash required")
|
||||
return
|
||||
}
|
||||
credited, err := s.ln.SettleDeposit(r.Context(), req.PaymentHash)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"settled": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settled": true,
|
||||
"credited_msat": credited,
|
||||
})
|
||||
}
|
||||
|
||||
type withdrawRequest struct {
|
||||
Bolt11 string `json:"bolt11"`
|
||||
AmountSat int64 `json:"amount_sats"`
|
||||
}
|
||||
|
||||
func (s *server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ln == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||
return
|
||||
}
|
||||
acctID, _, ok := s.account(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||
return
|
||||
}
|
||||
var req withdrawRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Bolt11 == "" || req.AmountSat < 1 {
|
||||
writeErr(w, http.StatusBadRequest, "bolt11 and amount_sats required (>=1)")
|
||||
return
|
||||
}
|
||||
id, err := s.ln.RequestWithdrawal(r.Context(), acctID, req.Bolt11, req.AmountSat*1000)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"withdrawal_id": id,
|
||||
"status": "queued",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the last frame each hub saw rather than the local room object:
|
||||
// on an instance that does not lead a game, the local room is idle and
|
||||
@@ -608,6 +832,75 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleTournaments lists what a player can currently join or watch.
|
||||
func (s *server) handleTournaments(w http.ResponseWriter, r *http.Request) {
|
||||
active, err := s.tournaments.Active(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tournaments": active})
|
||||
}
|
||||
|
||||
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||
return
|
||||
}
|
||||
board, err := s.tournaments.Leaderboard(r.Context(), id, 100)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"standings": board})
|
||||
}
|
||||
|
||||
func (s *server) handleEnterTournament(w http.ResponseWriter, r *http.Request) {
|
||||
account, _, ok := s.account(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||
return
|
||||
}
|
||||
if err := s.tournaments.Enter(r.Context(), id, account); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
bal, _ := s.ledger.Balance(r.Context(), account)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { Arcade3D } from '/scene3d.js';
|
||||
import * as charts from '/charts.js';
|
||||
import * as qr from '/qr.js';
|
||||
|
||||
const KEY_STORAGE = 'quantum-arcade-key';
|
||||
const NAME_STORAGE = 'quantum-arcade-name';
|
||||
@@ -141,6 +142,8 @@ async function signIn() {
|
||||
|
||||
connect(currentGame);
|
||||
loadScratch();
|
||||
detectLightning();
|
||||
startTour();
|
||||
}
|
||||
|
||||
/* ---------------- api ---------------- */
|
||||
@@ -297,26 +300,54 @@ function onSnapshot(s) {
|
||||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||
$('state').textContent =
|
||||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
action.textContent = 'Next round';
|
||||
action.textContent = 'Bet again';
|
||||
action.className = 'primary big';
|
||||
action.disabled = true;
|
||||
action.disabled = false;
|
||||
|
||||
// Record the round once, on the transition into settled.
|
||||
if (!wasSettled && s.crash_point) {
|
||||
stats.crashes.push(crash);
|
||||
if (myBet === 'in') {
|
||||
stats.losses++;
|
||||
hint.textContent = 'Rode it too far.';
|
||||
hint.className = 'hint bad';
|
||||
// Near-miss psychology: show how close they were
|
||||
const autoTarget = parseFloat($('auto-target').value || '0');
|
||||
if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
|
||||
hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
|
||||
hint.className = 'hint near-miss';
|
||||
} else {
|
||||
hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
|
||||
hint.className = 'hint bad';
|
||||
}
|
||||
stats.streak = 0;
|
||||
buzz(120);
|
||||
} else if (myBet === 'out') {
|
||||
stats.wins++;
|
||||
buzz([30, 40, 30]);
|
||||
stats.streak = (stats.streak || 0) + 1;
|
||||
const payout = Math.round(stake * crash / 1000);
|
||||
hint.textContent = stats.streak >= 5
|
||||
? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
|
||||
: stats.streak >= 3
|
||||
? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
|
||||
: `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
|
||||
hint.className = 'hint good';
|
||||
if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
|
||||
else buzz([30, 40, 30]);
|
||||
// Auto-increment stake on hot streak
|
||||
if (stats.streak >= 3 && stake < 25000) {
|
||||
const newStake = stake * 2;
|
||||
setStake(newStake);
|
||||
hint.textContent += ' • Stake doubled!';
|
||||
}
|
||||
}
|
||||
if (!stats.best || crash > stats.best) stats.best = crash;
|
||||
saveStats();
|
||||
renderStrip();
|
||||
refreshBalance();
|
||||
}
|
||||
// Pre-fill for instant re-bet: auto-bet on next round
|
||||
if (!wasSettled && myBet === 'out') {
|
||||
action.classList.add('pulse');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -360,6 +391,11 @@ async function onAction() {
|
||||
hint.className = 'hint';
|
||||
try {
|
||||
if (snapshot.state === 'betting_open' && !myBet) {
|
||||
if (!(await confirmRealMoney(`Bet ${sats(stake)} sats?`,
|
||||
'Rounds are played with real satoshis. If the game crashes before ' +
|
||||
'you cash out, the stake is lost.'))) {
|
||||
return;
|
||||
}
|
||||
const r = await api('POST', '/api/bet', {
|
||||
game: currentGame, stake_msat: stake, nickname,
|
||||
auto_cashout: autoTarget(),
|
||||
@@ -537,6 +573,282 @@ function renderPortfolio() {
|
||||
: '';
|
||||
}
|
||||
|
||||
/* ---------------- lightning ----------------
|
||||
*
|
||||
* The deposit and withdraw cards stay hidden unless the server reports a node,
|
||||
* so a play-money deployment does not advertise a deposit it cannot honour. */
|
||||
|
||||
let depositHash = null;
|
||||
let depositPoll = null;
|
||||
|
||||
async function detectLightning() {
|
||||
try {
|
||||
// A zero-amount request is rejected either way; what distinguishes the two
|
||||
// cases is whether the server says "not configured" or "bad amount".
|
||||
const res = await fetch('/api/deposit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + token },
|
||||
body: JSON.stringify({ amount_sats: 0 }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const configured = !(data.error || '').includes('lightning not configured');
|
||||
$('card-deposit').hidden = !configured;
|
||||
$('card-withdraw').hidden = !configured;
|
||||
} catch {
|
||||
$('card-deposit').hidden = true;
|
||||
$('card-withdraw').hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeposit() {
|
||||
const hint = $('dep-hint');
|
||||
hint.textContent = '';
|
||||
hint.className = 'hint';
|
||||
|
||||
const amount = Math.floor(Number($('dep-amt').value));
|
||||
if (!Number.isFinite(amount) || amount < 1) {
|
||||
hint.textContent = 'Enter an amount in sats.';
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await api('POST', '/api/deposit', { amount_sats: amount });
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
|
||||
depositHash = res.payment_hash;
|
||||
$('dep-bolt11').textContent = res.invoice;
|
||||
$('dep-invoice').hidden = false;
|
||||
|
||||
const wrap = $('dep-qr');
|
||||
clear(wrap);
|
||||
// Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
|
||||
// more densely, which keeps the symbol scannable on a phone screen.
|
||||
const matrix = qr.encode(res.invoice.toUpperCase());
|
||||
if (matrix) {
|
||||
wrap.appendChild(qr.render(matrix));
|
||||
} else {
|
||||
wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
|
||||
}
|
||||
|
||||
$('dep-status').textContent = 'Waiting for payment…';
|
||||
startDepositPolling();
|
||||
}
|
||||
|
||||
/* Poll for settlement. The server confirms with the node before crediting, so
|
||||
* this cannot be used to claim a payment that never arrived. */
|
||||
function startDepositPolling() {
|
||||
if (depositPoll) clearInterval(depositPoll);
|
||||
let attempts = 0;
|
||||
depositPoll = setInterval(async () => {
|
||||
if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
|
||||
clearInterval(depositPoll);
|
||||
depositPoll = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api('POST', '/api/deposit/check',
|
||||
{ payment_hash: depositHash });
|
||||
if (res.credited_msat > 0 || res.settled) {
|
||||
clearInterval(depositPoll);
|
||||
depositPoll = null;
|
||||
depositHash = null;
|
||||
$('dep-status').textContent = 'Paid. Balance updated.';
|
||||
$('dep-invoice').hidden = true;
|
||||
buzz([40, 50, 40]);
|
||||
await refreshBalance();
|
||||
loadHistory();
|
||||
}
|
||||
} catch {
|
||||
// Not settled yet is the normal case and arrives as an error; keep
|
||||
// waiting rather than treating it as a failure.
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* Cash out by showing a code the player's wallet pulls from. The invoice-paste
|
||||
* path stays available for wallets that cannot scan LNURL, but it is folded
|
||||
* away because it is the step most people give up on. */
|
||||
async function withdrawByCode() {
|
||||
const hint = $('wd-hint');
|
||||
hint.textContent = '';
|
||||
hint.className = 'hint';
|
||||
|
||||
let amount = Math.floor(Number($('wd-amt').value));
|
||||
if (!Number.isFinite(amount) || amount < 1) {
|
||||
hint.textContent = 'Pick an amount first.';
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirmRealMoney(`Cash out ${amount.toLocaleString()} sats?`,
|
||||
'This sends real satoshis to your wallet and removes them from your ' +
|
||||
'arcade balance.'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await api('POST', '/api/withdraw/code', { amount_sats: amount });
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
|
||||
setBalance(res.balance_msat);
|
||||
|
||||
const wrap = $('wd-qr');
|
||||
clear(wrap);
|
||||
const matrix = qr.encode(res.lnurl);
|
||||
if (matrix) {
|
||||
wrap.appendChild(qr.render(matrix, { foreground: '#ffb000' }));
|
||||
} else {
|
||||
wrap.textContent = 'Could not render the code — copy it instead.';
|
||||
}
|
||||
$('wd-code').hidden = false;
|
||||
$('wd-copy').dataset.code = res.lnurl;
|
||||
$('wd-expiry').textContent =
|
||||
`Scan within ${Math.round(res.expires_in / 60)} minutes. ` +
|
||||
'If you do not, the sats come back to your balance.';
|
||||
|
||||
hint.className = 'hint good';
|
||||
hint.textContent = 'Scan with your Lightning wallet.';
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
/* The manual path, for wallets without LNURL support. */
|
||||
async function withdrawToInvoice() {
|
||||
const hint = $('wd-hint');
|
||||
hint.textContent = '';
|
||||
hint.className = 'hint';
|
||||
|
||||
const bolt11 = $('wd-bolt11').value.trim();
|
||||
const amount = Math.floor(Number($('wd-amt').value));
|
||||
if (!bolt11.toLowerCase().startsWith('ln')) {
|
||||
hint.textContent = 'That does not look like a Lightning invoice.';
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount < 1) {
|
||||
hint.textContent = 'Enter the amount the invoice is for.';
|
||||
hint.className = 'hint bad';
|
||||
return;
|
||||
}
|
||||
if (!(await confirmRealMoney(`Send ${amount.toLocaleString()} sats?`,
|
||||
'This pays the invoice you pasted with real satoshis.'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('POST', '/api/withdraw', { bolt11, amount_sats: amount });
|
||||
hint.className = 'hint good';
|
||||
hint.textContent = res.status === 'needs_approval'
|
||||
? 'Queued for approval — large cash-outs are reviewed.'
|
||||
: 'Queued. It should arrive within about fifteen seconds.';
|
||||
$('wd-bolt11').value = '';
|
||||
await refreshBalance();
|
||||
loadHistory();
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
hint.className = 'hint bad';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- real-money confirmation ----------------
|
||||
*
|
||||
* Shown once, the first time a player moves real value. The interface is
|
||||
* deliberately frictionless, and the one thing worth a moment of friction is
|
||||
* someone not registering that the sats are real. */
|
||||
|
||||
const CONFIRMED_KEY = 'quantum-arcade-understands-real-money';
|
||||
|
||||
function confirmRealMoney(title, body) {
|
||||
if (localStorage.getItem(CONFIRMED_KEY) === '1') return Promise.resolve(true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const overlay = $('tour');
|
||||
overlay.classList.add('confirm');
|
||||
overlay.hidden = false;
|
||||
$('tour-step').textContent = 'Real satoshis';
|
||||
$('tour-title').textContent = title;
|
||||
$('tour-body').textContent = body;
|
||||
clear($('tour-dots'));
|
||||
$('tour-next').textContent = 'I understand';
|
||||
$('tour-skip').textContent = 'Cancel';
|
||||
|
||||
const finish = (ok) => {
|
||||
overlay.hidden = true;
|
||||
overlay.classList.remove('confirm');
|
||||
$('tour-skip').textContent = 'Skip';
|
||||
if (ok) localStorage.setItem(CONFIRMED_KEY, '1');
|
||||
resolve(ok);
|
||||
};
|
||||
$('tour-next').onclick = () => finish(true);
|
||||
$('tour-skip').onclick = () => finish(false);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- first-run walkthrough ---------------- */
|
||||
|
||||
const TOUR_KEY = 'quantum-arcade-tour-done';
|
||||
|
||||
const TOUR = [
|
||||
{
|
||||
title: 'You are already signed in',
|
||||
body: 'No account, no password. This device made a key that is your ' +
|
||||
'identity. Keep the device, keep the balance.',
|
||||
},
|
||||
{
|
||||
title: 'Bet, then get out',
|
||||
body: 'The number climbs. Tap Cash out before it crashes and you keep ' +
|
||||
'the multiple. Wait too long and the stake is gone.',
|
||||
},
|
||||
{
|
||||
title: 'Nobody can rig it',
|
||||
body: 'The result is sealed before betting opens and mixed with every ' +
|
||||
'player\'s key. Tap Verify after any round to check it yourself.',
|
||||
},
|
||||
];
|
||||
|
||||
function startTour() {
|
||||
if (localStorage.getItem(TOUR_KEY) === '1') return;
|
||||
|
||||
let i = 0;
|
||||
const overlay = $('tour');
|
||||
overlay.hidden = false;
|
||||
|
||||
const render = () => {
|
||||
const step = TOUR[i];
|
||||
$('tour-step').textContent = `Step ${i + 1} of ${TOUR.length}`;
|
||||
$('tour-title').textContent = step.title;
|
||||
$('tour-body').textContent = step.body;
|
||||
$('tour-next').textContent = i === TOUR.length - 1 ? 'Play' : 'Got it';
|
||||
|
||||
const dots = $('tour-dots');
|
||||
clear(dots);
|
||||
TOUR.forEach((_, n) => dots.appendChild(el('span', { class: n === i ? 'on' : '' })));
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
overlay.hidden = true;
|
||||
localStorage.setItem(TOUR_KEY, '1');
|
||||
};
|
||||
|
||||
$('tour-next').onclick = () => {
|
||||
if (++i >= TOUR.length) finish();
|
||||
else render();
|
||||
};
|
||||
$('tour-skip').onclick = finish;
|
||||
render();
|
||||
}
|
||||
|
||||
/* ---------------- wallet ---------------- */
|
||||
|
||||
async function loadHistory() {
|
||||
@@ -695,6 +1007,41 @@ async function init() {
|
||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||
|
||||
$('auto-target').oninput = refreshAutoRow;
|
||||
|
||||
$('do-deposit').onclick = createDeposit;
|
||||
$('do-withdraw').onclick = withdrawByCode;
|
||||
$('do-withdraw-manual').onclick = withdrawToInvoice;
|
||||
$('wd-copy').onclick = (e) =>
|
||||
navigator.clipboard.writeText(e.currentTarget.dataset.code || '');
|
||||
document.querySelectorAll('[data-wd]').forEach((b) => {
|
||||
b.onclick = () => {
|
||||
$('wd-amt').value = b.dataset.wd === 'all'
|
||||
? Math.floor(balanceMsat / 1000)
|
||||
: b.dataset.wd;
|
||||
};
|
||||
});
|
||||
$('dep-copy').onclick = () =>
|
||||
navigator.clipboard.writeText($('dep-bolt11').textContent);
|
||||
$('dep-check').onclick = async () => {
|
||||
if (!depositHash) return;
|
||||
try {
|
||||
const res = await api('POST', '/api/deposit/check',
|
||||
{ payment_hash: depositHash });
|
||||
if (res.credited_msat > 0 || res.settled) {
|
||||
$('dep-status').textContent = 'Paid. Balance updated.';
|
||||
$('dep-invoice').hidden = true;
|
||||
depositHash = null;
|
||||
await refreshBalance();
|
||||
} else {
|
||||
$('dep-status').textContent = 'Not settled yet — still waiting.';
|
||||
}
|
||||
} catch (e) {
|
||||
$('dep-status').textContent = e.message;
|
||||
}
|
||||
};
|
||||
document.querySelectorAll('[data-dep]').forEach((b) => {
|
||||
b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
|
||||
});
|
||||
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||||
b.onclick = () => {
|
||||
$('auto-target').value = b.dataset.auto;
|
||||
|
||||
138
cmd/arcade/static/costs.html
Normal file
138
cmd/arcade/static/costs.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>WHAT THIS COSTS :: QUANTUM ARCADE</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<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>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||
</header>
|
||||
|
||||
<main class="view costs">
|
||||
|
||||
<h1>WHAT THIS COSTS</h1>
|
||||
|
||||
<p class="lede">
|
||||
Running this takes electricity, a machine, and a Lightning node with money
|
||||
parked in it. Two small deductions cover that. Both are listed here, both
|
||||
appear as their own line in your transaction history, and both are computed
|
||||
by the same code that generated this page.
|
||||
</p>
|
||||
|
||||
<!-- Filled from /api/fees, so this page cannot state terms different from
|
||||
the ones the server applies. -->
|
||||
<div class="kvgrid" id="schedule"></div>
|
||||
|
||||
<h2>The two deductions</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>1. A percentage of winnings</h3>
|
||||
<p class="muted small">
|
||||
Taken only when you win. If you lose a round, nothing extra is taken —
|
||||
you simply lost the round. This is the house's actual revenue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>2. Rounding down to whole satoshis</h3>
|
||||
<p class="muted small">
|
||||
Balances are tracked in millisatoshis — thousandths of a satoshi — because
|
||||
the maths needs that resolution. Payouts are floored to whole satoshis and
|
||||
the fraction stays with the house.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
The most this can ever cost you on a single payout is
|
||||
<strong id="worst">—</strong>. It is a rounding, not a second fee, and it
|
||||
is bounded by that amount every time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>What that does to your odds</h2>
|
||||
|
||||
<p class="muted small">
|
||||
A rake changes the real return, so quoting the game's raw figure would be
|
||||
misleading. Both numbers are below: what the game's maths return before the
|
||||
deduction, and what you actually receive after it.
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table class="odds" id="rtp-table">
|
||||
<tr><th>game</th><th>maths return</th><th>you receive</th></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>How you can check all of this</h2>
|
||||
|
||||
<ul class="checks">
|
||||
<li>
|
||||
<strong>Your history itemises it.</strong> Open the Wallet tab. A win
|
||||
shows as a <code>payout</code> line for the full amount, followed by an
|
||||
<code>operating_fee</code> line for the deduction. Nothing is folded into
|
||||
a quietly smaller number.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The books must sum to zero.</strong> Every millisatoshi in this
|
||||
system is a double-entry posting.
|
||||
<code>/api/health</code> adds up every account in the system; it returns
|
||||
zero or the platform is telling you it is broken. A fee that vanished
|
||||
instead of being posted would show up there.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The odds are the generator.</strong> The scratch odds tables come
|
||||
from the same data structure that produces outcomes — they cannot drift
|
||||
apart. A test runs two million plays and fails the build if the observed
|
||||
frequencies disagree with the published ones.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Outcomes are sealed before you bet.</strong> The crash point comes
|
||||
from a seed committed before betting opens, combined with the keys of
|
||||
everyone who joined. Check any round yourself in the Verify tab; it
|
||||
recomputes on your device and asks the server only for published values.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The code is open.</strong> AGPL-3.0. Every line of this,
|
||||
including the two deductions described above, is readable and auditable.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>What is not taken</h2>
|
||||
|
||||
<ul class="checks">
|
||||
<li>No fee to deposit.</li>
|
||||
<li>No fee to send sats to another player.</li>
|
||||
<li>No fee on losing rounds beyond the loss itself.</li>
|
||||
<li>No account fee, inactivity fee, or minimum balance.</li>
|
||||
<li>Withdrawals cost only the Lightning routing fee, which is real network
|
||||
cost and is capped.</li>
|
||||
</ul>
|
||||
|
||||
<p class="muted small closing">
|
||||
The aim is for this to feel free, which means being exact about the places
|
||||
it is not. If you find a number on this page that does not match what your
|
||||
history shows, that is a bug worth reporting, and the ledger will settle
|
||||
the argument.
|
||||
</p>
|
||||
|
||||
<p class="center"><a class="backlink" href="/">← back to the arcade</a></p>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/costs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
cmd/arcade/static/costs.js
Normal file
62
cmd/arcade/static/costs.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/* The costs page.
|
||||
*
|
||||
* Every figure is fetched from /api/fees, which the server renders from the
|
||||
* same schedule it charges. Nothing here is written by hand, so the published
|
||||
* terms cannot drift from the behaviour — if the operator changes the rake,
|
||||
* this page changes with it. */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let d;
|
||||
try {
|
||||
d = await (await fetch('/api/fees')).json();
|
||||
} catch {
|
||||
$('schedule').textContent = 'Could not load the fee schedule.';
|
||||
return;
|
||||
}
|
||||
|
||||
const s = d.schedule || {};
|
||||
const grid = $('schedule');
|
||||
const rows = [
|
||||
['taken from winnings', s.rake_percent],
|
||||
['payouts rounded to', s.rounding_unit],
|
||||
['most rounding can cost', 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 || '—' })));
|
||||
}
|
||||
$('worst').textContent = s.worst_case_rounding_per_payout || '—';
|
||||
|
||||
const table = $('rtp-table');
|
||||
const crash = d.crash_games || {};
|
||||
table.appendChild(el('tr', {},
|
||||
el('td', { text: 'Crash games' }),
|
||||
el('td', { text: crash.game_rtp_percent || '—' }),
|
||||
el('td', { class: 'rtp', text: crash.effective_percent || '—' })));
|
||||
|
||||
for (const t of d.scratch_tickets || []) {
|
||||
table.appendChild(el('tr', {},
|
||||
el('td', { text: t.ticket }),
|
||||
el('td', { text: t.game_rtp_percent }),
|
||||
el('td', { class: 'rtp', text: t.effective_percent })));
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -93,6 +93,10 @@
|
||||
<button data-auto="5">5</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="explain" id="auto-explain">
|
||||
Leave this off to cash out by tapping. Set it and you stop
|
||||
automatically — 2× means you double your stake and get out.
|
||||
</p>
|
||||
|
||||
<button class="primary big" id="action">Place bet</button>
|
||||
<div class="hint" id="hint"></div>
|
||||
@@ -177,6 +181,66 @@
|
||||
<code class="pubkey" id="pubkey">—</code>
|
||||
<button id="copykey">Copy</button>
|
||||
</div>
|
||||
<!-- Lightning in. Hidden unless the server has a node configured, so a
|
||||
play-money deployment does not advertise a deposit it cannot take. -->
|
||||
<div class="card" id="card-deposit" hidden>
|
||||
<h2>Add sats</h2>
|
||||
<div class="stakerow">
|
||||
<button class="chip" data-dep="1000">1k</button>
|
||||
<button class="chip" data-dep="5000">5k</button>
|
||||
<button class="chip" data-dep="25000">25k</button>
|
||||
<input id="dep-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||
</div>
|
||||
<button class="primary" id="do-deposit">Create invoice</button>
|
||||
<div class="hint" id="dep-hint"></div>
|
||||
|
||||
<div id="dep-invoice" hidden>
|
||||
<div class="qrwrap" id="dep-qr"></div>
|
||||
<code class="pubkey" id="dep-bolt11"></code>
|
||||
<div class="stakerow">
|
||||
<button id="dep-copy">Copy invoice</button>
|
||||
<button id="dep-check">I have paid</button>
|
||||
</div>
|
||||
<p class="muted small" id="dep-status">
|
||||
Scan with any Lightning wallet. Your balance updates once it settles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning out. Scanning is the whole flow: no invoice to create, no
|
||||
amount to type into a second app. -->
|
||||
<div class="card" id="card-withdraw" hidden>
|
||||
<h2>Cash out</h2>
|
||||
<p class="muted small">
|
||||
Pick an amount and scan the code with your Lightning wallet. Your
|
||||
wallet pulls the sats — you never make an invoice.
|
||||
</p>
|
||||
<div class="stakerow">
|
||||
<button class="chip" data-wd="1000">1k</button>
|
||||
<button class="chip" data-wd="5000">5k</button>
|
||||
<button class="chip" data-wd="all">All</button>
|
||||
<input id="wd-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||
</div>
|
||||
<button class="primary" id="do-withdraw">Show cash-out code</button>
|
||||
<div class="hint" id="wd-hint"></div>
|
||||
|
||||
<div id="wd-code" hidden>
|
||||
<div class="qrwrap" id="wd-qr"></div>
|
||||
<p class="muted small center" id="wd-expiry"></p>
|
||||
<button id="wd-copy">Copy code</button>
|
||||
</div>
|
||||
|
||||
<details class="proof">
|
||||
<summary>Paste an invoice instead</summary>
|
||||
<p class="muted small">
|
||||
If your wallet cannot scan LNURL, make an invoice for the amount and
|
||||
paste it here.
|
||||
</p>
|
||||
<input id="wd-bolt11" placeholder="lnbc… invoice" autocomplete="off">
|
||||
<button id="do-withdraw-manual">Withdraw to invoice</button>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Send sats</h2>
|
||||
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||
@@ -254,6 +318,18 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- First run only. Three cards, then it never appears again. -->
|
||||
<div class="tour" id="tour" hidden>
|
||||
<div class="tourcard">
|
||||
<div class="tourstep" id="tour-step"></div>
|
||||
<h2 id="tour-title"></h2>
|
||||
<p id="tour-body"></p>
|
||||
<div class="tourdots" id="tour-dots"></div>
|
||||
<button class="primary" id="tour-next">Got it</button>
|
||||
<button class="tourskip" id="tour-skip">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
cmd/arcade/static/pqsign.wasm
Executable file
BIN
cmd/arcade/static/pqsign.wasm
Executable file
Binary file not shown.
358
cmd/arcade/static/qr.js
Normal file
358
cmd/arcade/static/qr.js
Normal file
@@ -0,0 +1,358 @@
|
||||
/* A minimal QR encoder, byte mode, error-correction level M.
|
||||
*
|
||||
* Written rather than imported because the arcade has to work on a network
|
||||
* with no internet: a CDN script is a dependency on the outside world, and the
|
||||
* whole point of this box is that it does not need one.
|
||||
*
|
||||
* Scope is deliberately narrow — byte mode, versions 1 through 20, level M —
|
||||
* which covers a Lightning invoice with room to spare and leaves out the
|
||||
* kanji/numeric modes and structured-append machinery that would triple the
|
||||
* size for no benefit here.
|
||||
*
|
||||
* Reference: ISO/IEC 18004. */
|
||||
|
||||
/* ---------- Galois field arithmetic over GF(256) ----------
|
||||
* Reed–Solomon needs multiplication in the field the QR spec defines, with
|
||||
* the primitive polynomial 0x11d. Log tables make that a lookup. */
|
||||
|
||||
const EXP = new Uint8Array(512);
|
||||
const LOG = new Uint8Array(256);
|
||||
(function buildTables() {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x100) x ^= 0x11d;
|
||||
}
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
|
||||
function gfMul(a, b) {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return EXP[LOG[a] + LOG[b]];
|
||||
}
|
||||
|
||||
/* The generator polynomial for n error-correction codewords. */
|
||||
function rsGenerator(n) {
|
||||
let poly = [1];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = new Array(poly.length + 1).fill(0);
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
next[j] ^= poly[j];
|
||||
next[j + 1] ^= gfMul(poly[j], EXP[i]);
|
||||
}
|
||||
poly = next;
|
||||
}
|
||||
return poly;
|
||||
}
|
||||
|
||||
function rsEncode(data, ecCount) {
|
||||
const gen = rsGenerator(ecCount);
|
||||
const res = new Uint8Array(data.length + ecCount);
|
||||
res.set(data);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const factor = res[i];
|
||||
if (factor === 0) continue;
|
||||
for (let j = 0; j < gen.length; j++) {
|
||||
res[i + j] ^= gfMul(gen[j], factor);
|
||||
}
|
||||
}
|
||||
return res.slice(data.length);
|
||||
}
|
||||
|
||||
/* ---------- capacity tables, level M ----------
|
||||
* [total codewords, ec codewords per block, block count group1,
|
||||
* data codewords group1, block count group2, data codewords group2] */
|
||||
const VERSIONS = [
|
||||
null,
|
||||
[26, 10, 1, 16, 0, 0], // 1
|
||||
[44, 16, 1, 28, 0, 0],
|
||||
[70, 26, 1, 44, 0, 0],
|
||||
[100, 18, 2, 32, 0, 0],
|
||||
[134, 24, 2, 43, 0, 0],
|
||||
[172, 16, 4, 27, 0, 0],
|
||||
[196, 18, 4, 31, 0, 0],
|
||||
[242, 22, 2, 38, 2, 39],
|
||||
[292, 22, 3, 36, 2, 37],
|
||||
[346, 26, 4, 43, 1, 44], // 10
|
||||
[404, 30, 1, 50, 4, 51],
|
||||
[466, 22, 6, 36, 2, 37],
|
||||
[532, 22, 8, 37, 1, 38],
|
||||
[581, 24, 4, 40, 5, 41],
|
||||
[655, 24, 5, 41, 5, 42], // 15
|
||||
[733, 28, 7, 45, 3, 46],
|
||||
[815, 28, 10, 46, 1, 47],
|
||||
[901, 26, 9, 43, 4, 44],
|
||||
[991, 26, 3, 44, 11, 45],
|
||||
[1085, 26, 3, 41, 13, 42], // 20
|
||||
];
|
||||
|
||||
function versionCapacity(v) {
|
||||
const [, ec, b1, d1, b2, d2] = VERSIONS[v];
|
||||
return b1 * d1 + b2 * d2;
|
||||
}
|
||||
|
||||
/* Alignment pattern centres per version. */
|
||||
const ALIGN = [
|
||||
[], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
|
||||
[6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
|
||||
[6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
|
||||
[6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
|
||||
[6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
|
||||
];
|
||||
|
||||
/* ---------- bit stream ---------- */
|
||||
|
||||
class Bits {
|
||||
constructor() { this.bits = []; }
|
||||
push(value, length) {
|
||||
for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
|
||||
}
|
||||
get length() { return this.bits.length; }
|
||||
toBytes() {
|
||||
const out = new Uint8Array(Math.ceil(this.bits.length / 8));
|
||||
this.bits.forEach((b, i) => { if (b) out[i >> 3] |= 0x80 >> (i & 7); });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- encoding ---------- */
|
||||
|
||||
function encodeData(text, version) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const bits = new Bits();
|
||||
|
||||
bits.push(0b0100, 4); // byte mode
|
||||
bits.push(bytes.length, version < 10 ? 8 : 16); // length field
|
||||
for (const b of bytes) bits.push(b, 8);
|
||||
|
||||
const capacityBits = versionCapacity(version) * 8;
|
||||
if (bits.length > capacityBits) return null; // does not fit
|
||||
|
||||
// Terminator, then pad to a byte boundary, then alternating pad bytes.
|
||||
bits.push(0, Math.min(4, capacityBits - bits.length));
|
||||
while (bits.length % 8 !== 0) bits.push(0, 1);
|
||||
|
||||
const data = Array.from(bits.toBytes());
|
||||
const padBytes = [0xec, 0x11];
|
||||
let i = 0;
|
||||
while (data.length < versionCapacity(version)) data.push(padBytes[i++ % 2]);
|
||||
|
||||
return interleave(data, version);
|
||||
}
|
||||
|
||||
/* Split into blocks, compute error correction, then interleave both — the
|
||||
* spec's arrangement, which is what makes a QR survive damage to any one
|
||||
* region rather than losing a contiguous run of data. */
|
||||
function interleave(data, version) {
|
||||
const [, ecPerBlock, b1, d1, b2, d2] = VERSIONS[version];
|
||||
|
||||
const blocks = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < b1; i++) {
|
||||
blocks.push(data.slice(offset, offset + d1));
|
||||
offset += d1;
|
||||
}
|
||||
for (let i = 0; i < b2; i++) {
|
||||
blocks.push(data.slice(offset, offset + d2));
|
||||
offset += d2;
|
||||
}
|
||||
|
||||
const ecBlocks = blocks.map((b) => rsEncode(Uint8Array.from(b), ecPerBlock));
|
||||
|
||||
const out = [];
|
||||
const maxData = Math.max(...blocks.map((b) => b.length));
|
||||
for (let i = 0; i < maxData; i++) {
|
||||
for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||
}
|
||||
for (let i = 0; i < ecPerBlock; i++) {
|
||||
for (const b of ecBlocks) out.push(b[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- matrix ---------- */
|
||||
|
||||
function buildMatrix(version, codewords, mask) {
|
||||
const size = version * 4 + 17;
|
||||
const m = Array.from({ length: size }, () => new Array(size).fill(null));
|
||||
|
||||
const setFinder = (r, c) => {
|
||||
for (let dr = -1; dr <= 7; dr++) {
|
||||
for (let dc = -1; dc <= 7; dc++) {
|
||||
const rr = r + dr, cc = c + dc;
|
||||
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 &&
|
||||
(dr === 0 || dr === 6 || dc === 0 || dc === 6 ||
|
||||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||
m[rr][cc] = inRing ? 1 : 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
setFinder(0, 0);
|
||||
setFinder(0, size - 7);
|
||||
setFinder(size - 7, 0);
|
||||
|
||||
// Timing patterns.
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
m[6][i] = i % 2 === 0 ? 1 : 0;
|
||||
m[i][6] = i % 2 === 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Alignment patterns, skipping those that would collide with finders.
|
||||
const centres = ALIGN[version];
|
||||
for (const r of centres) {
|
||||
for (const c of centres) {
|
||||
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) ||
|
||||
(r >= size - 9 && c <= 8)) continue;
|
||||
for (let dr = -2; dr <= 2; dr++) {
|
||||
for (let dc = -2; dc <= 2; dc++) {
|
||||
m[r + dr][c + dc] =
|
||||
Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m[size - 8][8] = 1; // dark module
|
||||
|
||||
// Reserve format areas so data placement skips them.
|
||||
const reserveFormat = () => {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (m[8][i] === null) m[8][i] = 0;
|
||||
if (m[i][8] === null) m[i][8] = 0;
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = 0;
|
||||
if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = 0;
|
||||
}
|
||||
};
|
||||
const formatCells = [];
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) if (m[r][c] === null) formatCells.push([r, c]);
|
||||
}
|
||||
reserveFormat();
|
||||
|
||||
// Version information, for version 7 and above.
|
||||
if (version >= 7) {
|
||||
let rem = version;
|
||||
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
|
||||
const bits = (version << 12) | rem;
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const bit = (bits >> i) & 1;
|
||||
m[Math.floor(i / 3)][size - 11 + (i % 3)] = bit;
|
||||
m[size - 11 + (i % 3)][Math.floor(i / 3)] = bit;
|
||||
}
|
||||
}
|
||||
|
||||
// Data placement: upward/downward in two-column strips, right to left.
|
||||
let bitIndex = 0;
|
||||
const dataBits = [];
|
||||
for (const cw of codewords) {
|
||||
for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
|
||||
}
|
||||
|
||||
let upward = true;
|
||||
for (let col = size - 1; col > 0; col -= 2) {
|
||||
if (col === 6) col--; // skip the timing column
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const cc = col - c;
|
||||
if (m[row][cc] !== null) continue;
|
||||
let bit = bitIndex < dataBits.length ? dataBits[bitIndex++] : 0;
|
||||
if (maskAt(mask, row, cc)) bit ^= 1;
|
||||
m[row][cc] = bit;
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
|
||||
writeFormat(m, size, mask);
|
||||
return m;
|
||||
}
|
||||
|
||||
function maskAt(mask, r, c) {
|
||||
switch (mask) {
|
||||
case 0: return (r + c) % 2 === 0;
|
||||
case 1: return r % 2 === 0;
|
||||
case 2: return c % 3 === 0;
|
||||
case 3: return (r + c) % 3 === 0;
|
||||
case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
|
||||
case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
|
||||
case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||
default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFormat(m, size, mask) {
|
||||
// Level M is 0b00; combine with the mask and append BCH error correction.
|
||||
const data = (0b00 << 3) | mask;
|
||||
let rem = data;
|
||||
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
|
||||
const bits = ((data << 10) | rem) ^ 0x5412;
|
||||
|
||||
for (let i = 0; i <= 5; i++) m[8][i] = (bits >> i) & 1;
|
||||
m[8][7] = (bits >> 6) & 1;
|
||||
m[8][8] = (bits >> 7) & 1;
|
||||
m[7][8] = (bits >> 8) & 1;
|
||||
for (let i = 9; i < 15; i++) m[14 - i][8] = (bits >> i) & 1;
|
||||
|
||||
// The second copy is 7 bits down the bottom-left column and 8 bits along
|
||||
// the top-right row. It is 7 and not 8 down the column because the cell
|
||||
// below is the dark module, which is fixed and must not be overwritten.
|
||||
for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = (bits >> i) & 1;
|
||||
for (let i = 7; i < 15; i++) m[8][size - 15 + i] = (bits >> i) & 1;
|
||||
}
|
||||
|
||||
/* ---------- public API ---------- */
|
||||
|
||||
/* encode returns a square matrix of 0/1, or null if the text does not fit. */
|
||||
export function encode(text) {
|
||||
for (let version = 1; version <= 20; version++) {
|
||||
const codewords = encodeData(text, version);
|
||||
if (codewords) {
|
||||
// Mask 0 is used unconditionally. Choosing the optimal mask by penalty
|
||||
// score improves scan reliability marginally and costs four more passes
|
||||
// over the matrix; at the sizes here, every reader handles mask 0.
|
||||
return buildMatrix(version, codewords, 0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* render draws a matrix into an SVG element, scaled to its container. */
|
||||
export function render(matrix, options = {}) {
|
||||
const quiet = options.quiet ?? 4;
|
||||
const size = matrix.length;
|
||||
const total = size + quiet * 2;
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', `0 0 ${total} ${total}`);
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('height', '100%');
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
|
||||
const bg = document.createElementNS(NS, 'rect');
|
||||
bg.setAttribute('width', total);
|
||||
bg.setAttribute('height', total);
|
||||
bg.setAttribute('fill', options.background ?? '#000603');
|
||||
svg.appendChild(bg);
|
||||
|
||||
// One path for every dark module: far fewer nodes than a rect each, which
|
||||
// matters when a phone is re-rendering this inside a live page.
|
||||
let d = '';
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (matrix[r][c]) d += `M${c + quiet} ${r + quiet}h1v1h-1z`;
|
||||
}
|
||||
}
|
||||
const path = document.createElementNS(NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', options.foreground ?? '#00ff9c');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
@@ -258,6 +258,22 @@ button:active { transform: translateY(1px); }
|
||||
}
|
||||
.hint.bad { color: var(--red); }
|
||||
.hint.good { color: var(--amber); }
|
||||
.hint.near-miss { color: #ff9f43; font-weight: 600; animation: flicker 0.6s ease-in-out 2; }
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Pulse the action button after a win — urge to rebet */
|
||||
#action.pulse {
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
box-shadow: 0 0 18px rgba(0, 255, 145, 0.4);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.player {
|
||||
@@ -511,3 +527,114 @@ button:active { transform: translateY(1px); }
|
||||
.multiplier.crashed { animation: none; }
|
||||
h1::after { animation: none; }
|
||||
}
|
||||
|
||||
/* ---------- costs / transparency page ---------- */
|
||||
|
||||
.costs { max-width: 660px; }
|
||||
.costs h1 { margin-bottom: 14px; }
|
||||
.costs h2 {
|
||||
margin-top: 26px; font-size: 12px; color: var(--amber);
|
||||
}
|
||||
.costs h3 {
|
||||
margin: 0 0 6px; font-size: 14px; letter-spacing: 0.06em;
|
||||
text-transform: none; color: var(--green);
|
||||
}
|
||||
.lede { font-size: 13.5px; line-height: 1.65; color: var(--ink, var(--green)); }
|
||||
|
||||
.kvgrid {
|
||||
display: grid; grid-template-columns: 1fr; gap: 1px;
|
||||
background: var(--green-ghost); border: 1px solid var(--green-ghost);
|
||||
margin: 14px 0;
|
||||
}
|
||||
@media (min-width: 560px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.kvgrid > div { background: #00120c; padding: 12px; }
|
||||
.kvgrid .k {
|
||||
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--green-dim); margin-bottom: 4px;
|
||||
}
|
||||
.kvgrid .v { font-size: 17px; color: var(--amber); font-weight: 700; }
|
||||
|
||||
.checks { padding-left: 18px; margin: 10px 0; }
|
||||
.checks li { margin-bottom: 10px; font-size: 12.5px; color: var(--green-dim); }
|
||||
.checks strong { color: var(--green); font-weight: 700; }
|
||||
.checks code {
|
||||
color: var(--amber); font-size: 11.5px;
|
||||
background: #00140e; padding: 1px 4px; border-radius: 2px;
|
||||
}
|
||||
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.closing {
|
||||
margin-top: 26px; padding-top: 14px;
|
||||
border-top: 1px solid var(--green-ghost);
|
||||
}
|
||||
.backlink {
|
||||
color: var(--green-dim); font-size: 12px; text-decoration: none;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.backlink:hover { color: var(--green); }
|
||||
|
||||
|
||||
/* ---------- lightning deposit QR ---------- */
|
||||
|
||||
.qrwrap {
|
||||
width: 100%; max-width: 260px; margin: 12px auto;
|
||||
aspect-ratio: 1; padding: 8px;
|
||||
background: #000603; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||
}
|
||||
.qrwrap svg { display: block; width: 100%; height: 100%; }
|
||||
|
||||
#dep-amt, #wd-amt { margin: 0; }
|
||||
.stakerow input { flex: 1.4; }
|
||||
|
||||
|
||||
/* ---------- plain-language help ---------- */
|
||||
|
||||
.explain {
|
||||
font-size: 11.5px; color: var(--green-dim); margin: 6px 2px 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ---------- first-run walkthrough ---------- */
|
||||
|
||||
.tour {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
background: #000000e8; backdrop-filter: blur(3px);
|
||||
display: grid; place-items: center; padding: 20px;
|
||||
}
|
||||
.tourcard {
|
||||
max-width: 340px; width: 100%; text-align: center;
|
||||
background: #00120c; border: 1px solid var(--green);
|
||||
border-radius: 3px; padding: 24px 20px;
|
||||
box-shadow: 0 0 40px #00ff9c22;
|
||||
}
|
||||
.tourstep {
|
||||
font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: var(--green-dim); margin-bottom: 10px;
|
||||
}
|
||||
.tourcard h2 {
|
||||
font-size: 16px; color: var(--green); text-transform: none;
|
||||
letter-spacing: 0.02em; margin-bottom: 10px;
|
||||
}
|
||||
.tourcard h2::before { content: none; }
|
||||
.tourcard p {
|
||||
font-size: 13px; line-height: 1.6; color: var(--green-dim);
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.tourdots { display: flex; gap: 6px; justify-content: center; margin-bottom: 16px; }
|
||||
.tourdots span {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--green-ghost);
|
||||
}
|
||||
.tourdots span.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.tourskip {
|
||||
width: 100%; margin-top: 8px; border: none; background: none;
|
||||
color: var(--green-dim); font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- real-money confirmation ---------- */
|
||||
|
||||
.confirm .tourcard { border-color: var(--amber); }
|
||||
.confirm h2 { color: var(--amber); }
|
||||
.confirm .primary {
|
||||
background: #2a1c00; color: var(--amber); border-color: var(--amber);
|
||||
}
|
||||
|
||||
575
cmd/arcade/static/wasm_exec.js
Normal file
575
cmd/arcade/static/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
139
cmd/pqsign/main.go
Normal file
139
cmd/pqsign/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Command pqsign exposes hybrid post-quantum signing to the browser.
|
||||
//
|
||||
// WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half has to come
|
||||
// from somewhere. Compiling the same pkg/pqid the server verifies with means
|
||||
// there is exactly one implementation of the scheme in the project: a client
|
||||
// and server that disagreed about signing would be a very expensive bug to
|
||||
// find, and this makes it impossible by construction.
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// GOOS=js GOARCH=wasm go build -o cmd/arcade/static/pqsign.wasm ./cmd/pqsign
|
||||
//
|
||||
// The private key never leaves the browser. It is generated here, exported for
|
||||
// the page to store, and re-imported on the next visit.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("qaPQ", js.ValueOf(map[string]any{
|
||||
"generateKey": js.FuncOf(generateKey),
|
||||
"sign": js.FuncOf(sign),
|
||||
"publicKey": js.FuncOf(publicKey),
|
||||
"sizes": js.FuncOf(sizes),
|
||||
}))
|
||||
|
||||
// A WASM module's main must not return, or the exported functions are
|
||||
// torn down with it.
|
||||
select {}
|
||||
}
|
||||
|
||||
// result wraps a value or an error in the shape the page expects, so JavaScript
|
||||
// never has to distinguish a thrown Go panic from a returned failure.
|
||||
func result(value any, err error) any {
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error()}
|
||||
}
|
||||
return map[string]any{"ok": value}
|
||||
}
|
||||
|
||||
// generateKey creates a hybrid keypair and returns both halves hex-encoded.
|
||||
//
|
||||
// The private half is handed to the page to persist. That is unavoidable —
|
||||
// the browser is where signing happens — but it never crosses the network.
|
||||
func generateKey(this js.Value, args []js.Value) any {
|
||||
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
|
||||
edSeed := priv.Ed.Seed()
|
||||
pqBytes, err := priv.PQ.MarshalBinary()
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
|
||||
return result(map[string]any{
|
||||
"public": pub.Hex(),
|
||||
"ed_seed": hex.EncodeToString(edSeed),
|
||||
"pq_key": hex.EncodeToString(pqBytes),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// sign produces both signatures over a hex-encoded message.
|
||||
//
|
||||
// qaPQ.sign(edSeedHex, pqKeyHex, messageHex) -> {ok: signatureHex}
|
||||
func sign(this js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
return result(nil, errArgs("sign expects (edSeed, pqKey, message)"))
|
||||
}
|
||||
|
||||
priv, err := restore(args[0].String(), args[1].String())
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
msg, err := hex.DecodeString(args[2].String())
|
||||
if err != nil {
|
||||
return result(nil, errArgs("message is not hex"))
|
||||
}
|
||||
|
||||
sig, err := pqid.Sign(priv, msg)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
return result(hex.EncodeToString(sig), nil)
|
||||
}
|
||||
|
||||
// publicKey re-derives the public half from stored private material, so the
|
||||
// page never has to store the public key separately and cannot store a pair
|
||||
// that does not match.
|
||||
func publicKey(this js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
return result(nil, errArgs("publicKey expects (edSeed, pqKey)"))
|
||||
}
|
||||
priv, err := restore(args[0].String(), args[1].String())
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
pub, err := pqid.PublicFromPrivate(priv)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
return result(pub.Hex(), nil)
|
||||
}
|
||||
|
||||
// sizes lets the page sanity-check what it stored without hardcoding lengths
|
||||
// that could drift from the Go side.
|
||||
func sizes(this js.Value, args []js.Value) any {
|
||||
return result(map[string]any{
|
||||
"public_key": pqid.PublicKeySize,
|
||||
"signature": pqid.SignatureSize,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func restore(edSeedHex, pqKeyHex string) (*pqid.PrivateKey, error) {
|
||||
edSeed, err := hex.DecodeString(edSeedHex)
|
||||
if err != nil {
|
||||
return nil, errArgs("ed seed is not hex")
|
||||
}
|
||||
pqBytes, err := hex.DecodeString(pqKeyHex)
|
||||
if err != nil {
|
||||
return nil, errArgs("pq key is not hex")
|
||||
}
|
||||
return pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||
}
|
||||
|
||||
type argError string
|
||||
|
||||
func (e argError) Error() string { return string(e) }
|
||||
|
||||
func errArgs(msg string) error { return argError("pqsign: " + msg) }
|
||||
@@ -37,6 +37,8 @@ services:
|
||||
ARCADE_ADDR: ":8080"
|
||||
# Development funding. Leave unset in any real deployment.
|
||||
ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}"
|
||||
ALBY_URL: http://10.30.20.43:58000
|
||||
ALBY_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwZXJtaXNzaW9uIjoiZnVsbCIsImV4cCI6MjY0OTg5MDE4OH0.IpemVy-_6PgWwlGtVtHRMZlpUR179jKamAl9fpbQE_o
|
||||
ports: ["8080:8080"]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
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);
|
||||
52
migrations/0006_tournaments.sql
Normal file
52
migrations/0006_tournaments.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Tournaments: scheduled events with an entry fee, a prize pool, and a
|
||||
-- leaderboard.
|
||||
--
|
||||
-- The prize pool is a real ledger account, not a number in a row. Entry fees
|
||||
-- move into it and prizes move out of it, so a tournament's money is subject to
|
||||
-- the same double-entry invariants as everything else and cannot be
|
||||
-- accidentally created or lost.
|
||||
|
||||
CREATE TYPE tournament_status AS ENUM
|
||||
('scheduled', 'registering', 'running', 'settled', 'cancelled');
|
||||
|
||||
CREATE TABLE tournaments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
status tournament_status NOT NULL DEFAULT 'scheduled',
|
||||
entry_fee_msat BIGINT NOT NULL CHECK (entry_fee_msat >= 0),
|
||||
-- The ledger account holding this tournament's pool.
|
||||
pool_account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
-- Prize split as basis points per finishing position, highest first.
|
||||
-- e.g. {5000,3000,2000} pays 50/30/20 to the top three.
|
||||
payout_bp INTEGER[] NOT NULL,
|
||||
max_entrants INTEGER,
|
||||
registers_at TIMESTAMPTZ NOT NULL,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
settled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT tournament_window CHECK (registers_at <= starts_at AND starts_at < ends_at)
|
||||
);
|
||||
|
||||
CREATE INDEX tournaments_status_idx ON tournaments (status, starts_at);
|
||||
|
||||
CREATE TABLE tournament_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tournament_id BIGINT NOT NULL REFERENCES tournaments(id),
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
-- Score is net profit in millisatoshis across the tournament window.
|
||||
-- It may be negative; a losing player still has a standing.
|
||||
score_msat BIGINT NOT NULL DEFAULT 0,
|
||||
rounds_played INTEGER NOT NULL DEFAULT 0,
|
||||
prize_msat BIGINT NOT NULL DEFAULT 0 CHECK (prize_msat >= 0),
|
||||
entered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tournament_id, account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX tournament_entries_board_idx
|
||||
ON tournament_entries (tournament_id, score_msat DESC);
|
||||
|
||||
-- Deliberately NOT append-only. An entry row is a seat reservation; a seat
|
||||
-- claimed but not paid for must be releasable so the player can retry once
|
||||
-- funded. The money side is a ledger posting and remains immutable.
|
||||
12
migrations/0007_entry_seats.sql
Normal file
12
migrations/0007_entry_seats.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Entry rows are a seat reservation, not a financial record.
|
||||
--
|
||||
-- They were created append-only alongside the ledger tables, but that conflates
|
||||
-- two different things. The ledger must be append-only because it is the record
|
||||
-- of money. A seat claimed and then not paid for is not a record of anything —
|
||||
-- it is a reservation that failed, and it must be releasable so the player can
|
||||
-- retry once funded.
|
||||
--
|
||||
-- The money side is unaffected: entry fees and prizes are ledger postings and
|
||||
-- remain immutable.
|
||||
|
||||
DROP TRIGGER IF EXISTS tournaments_append_only_entries ON tournament_entries;
|
||||
141
ops/README.md
Normal file
141
ops/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Operations
|
||||
|
||||
Persistence, failover, and tuning for a self-hosted arcade.
|
||||
|
||||
## Machines
|
||||
|
||||
| Role | Runs | Clone? |
|
||||
|---|---|---|
|
||||
| **core** | PostgreSQL, Redis, Caddy | no — one only |
|
||||
| **app** | `quantum-arcade` | yes, freely |
|
||||
| **standby** | PostgreSQL + `standby.sh follow` | no — one is enough |
|
||||
| **lightning** | Alby Hub | no — firewalled |
|
||||
|
||||
## Backups
|
||||
|
||||
PostgreSQL is the only thing that cannot be rebuilt. The app binary embeds its
|
||||
own client, and Redis holds only sessions and leases, which regenerate.
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/backups/quantum-arcade
|
||||
./ops/backup.sh init # once
|
||||
sudo cp ops/arcade-backup.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl enable --now arcade-backup.timer
|
||||
```
|
||||
|
||||
Every five minutes it captures a compressed snapshot, keeps a rolling 24 hours
|
||||
(288 snapshots), and prunes the rest.
|
||||
|
||||
**Every dump is checked before it replaces the previous one** — size and format
|
||||
header. A backup script that reports success on a truncated file is worse than
|
||||
no backup, because it converts a recoverable outage into silent data loss
|
||||
discovered only when it is needed.
|
||||
|
||||
### Prove it works
|
||||
|
||||
```bash
|
||||
./ops/backup.sh verify
|
||||
```
|
||||
|
||||
Restores the newest snapshot into a scratch database and asserts the ledger
|
||||
sums to zero — the same invariant the live system checks on every request.
|
||||
Schedule it nightly:
|
||||
|
||||
```bash
|
||||
sudo cp ops/arcade-verify.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl enable --now arcade-verify.timer
|
||||
```
|
||||
|
||||
A backup nobody has restored is a rumour.
|
||||
|
||||
## Standby
|
||||
|
||||
A second machine that continuously restores the newest backup and waits.
|
||||
|
||||
```bash
|
||||
sudo cp ops/arcade-standby.service /etc/systemd/system/
|
||||
sudo systemctl enable --now arcade-standby
|
||||
./ops/standby.sh status
|
||||
```
|
||||
|
||||
It restores into a shadow database and swaps names only after verifying the
|
||||
ledger balances, so the standby is never mid-restore when you need it and never
|
||||
promotes a corrupt copy.
|
||||
|
||||
### Pulling the plug
|
||||
|
||||
On the standby:
|
||||
|
||||
```bash
|
||||
./ops/standby.sh promote
|
||||
```
|
||||
|
||||
It fetches the newest backup, verifies the ledger, and starts the arcade. It
|
||||
does not contact the dead machine, because in the situation this exists for the
|
||||
dead machine is not answering.
|
||||
|
||||
Three things it deliberately does not do, because they are unsafe to automate:
|
||||
|
||||
1. **Repoint the endpoint.** DNS or the load balancer's upstream list. Until
|
||||
that happens players still reach the dead machine.
|
||||
2. **Confirm the old machine is down.** Two live instances writing to different
|
||||
databases diverge, and the result cannot be merged — both ledgers will be
|
||||
internally valid and mutually contradictory.
|
||||
3. **Let players in before checking `/api/health`.** It must report a zero
|
||||
ledger sum.
|
||||
|
||||
### What you lose
|
||||
|
||||
Up to one backup interval — five minutes of play. Rounds in flight at the
|
||||
moment of failure are refunded automatically by the reconciler once the
|
||||
standby is live, because their stakes were debited but never settled.
|
||||
|
||||
## Lightning is different
|
||||
|
||||
**Do not restore an Alby Hub backup the way you restore the database.**
|
||||
|
||||
Lightning channel state is not a snapshot you can roll back. Publishing an old
|
||||
channel state is interpreted by your counterparty as an attempt to cheat, and
|
||||
the penalty mechanism can take the entire channel balance. Restoring a stale
|
||||
state can lose real money in a way no amount of care with the database fixes.
|
||||
|
||||
Follow Alby Hub's own backup and recovery procedure. Keep the seed phrase
|
||||
offline and separate from the machine. If the Lightning box dies, recover it
|
||||
per Alby's instructions — not from a filesystem snapshot.
|
||||
|
||||
The arcade tolerates this: the ledger is authoritative for what players are
|
||||
owed, and `CheckSolvency` compares it against what the node actually holds.
|
||||
|
||||
## Kernel tuning
|
||||
|
||||
```bash
|
||||
./ops/tune-kernel.sh check # what is below target
|
||||
sudo ./ops/tune-kernel.sh apply
|
||||
```
|
||||
|
||||
Every value is tied to a measured limit, documented inline. The ones that
|
||||
matter most:
|
||||
|
||||
| Setting | Why |
|
||||
|---|---|
|
||||
| `fs.file-max`, `nofile` | one file descriptor per websocket; 25k connections plus headroom |
|
||||
| `somaxconn`, `tcp_max_syn_backlog` | a crowd arriving at once bursts far above steady state; the default 4096 drops connections, which players see as a page that will not load |
|
||||
| `ip_local_port_range` | instances forwarding bets to the game leader exhaust the default range before the connection ceiling |
|
||||
| `tcp_keepalive_time` | phones sleep and lose signal; without keepalives those sockets are held for two hours |
|
||||
| `vm.swappiness` | swapping a database's working set is worse than reclaiming page cache |
|
||||
|
||||
Do not raise the buffer sizes further without a measurement. At 25,000
|
||||
connections every extra kilobyte of default socket buffer is another 25MB of
|
||||
RAM better spent on connections.
|
||||
|
||||
## Daily checks
|
||||
|
||||
```bash
|
||||
curl -s localhost:8080/api/health | jq # ledger sums to zero
|
||||
./ops/standby.sh status # standby is current
|
||||
systemctl status arcade-backup.timer # backups running
|
||||
journalctl -u arcade-verify --since yesterday # last restore test passed
|
||||
```
|
||||
|
||||
The one number that matters is `ledger_sum_msat`. It is zero or the platform
|
||||
is telling you it is broken.
|
||||
14
ops/arcade-backup.service
Normal file
14
ops/arcade-backup.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Quantum Arcade incremental backup
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/quantum-arcade
|
||||
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||
ExecStart=/opt/quantum-arcade/ops/backup.sh sync
|
||||
# A failed backup must be loud. Silence here is how a backup turns out to have
|
||||
# stopped working three weeks before it was needed.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
13
ops/arcade-backup.timer
Normal file
13
ops/arcade-backup.timer
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Quantum Arcade backup every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
# Run a missed backup on boot rather than waiting for the next slot: the most
|
||||
# likely reason for a miss is the machine having been down.
|
||||
Persistent=true
|
||||
AccuracySec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
16
ops/arcade-standby.service
Normal file
16
ops/arcade-standby.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Quantum Arcade warm standby
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/quantum-arcade
|
||||
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||
Environment=ARCADE_STANDBY_INTERVAL=300
|
||||
ExecStart=/opt/quantum-arcade/ops/standby.sh follow
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
12
ops/arcade-verify.service
Normal file
12
ops/arcade-verify.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Prove the newest backup can actually be restored
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/quantum-arcade
|
||||
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||
# A backup nobody has restored is a rumour. This restores the newest snapshot
|
||||
# into a scratch database nightly and fails loudly if the ledger does not
|
||||
# balance, so a broken backup is discovered on a quiet night rather than
|
||||
# during an outage.
|
||||
ExecStart=/opt/quantum-arcade/ops/backup.sh verify
|
||||
10
ops/arcade-verify.timer
Normal file
10
ops/arcade-verify.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Quantum Arcade nightly backup restore test
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
192
ops/backup.sh
Executable file
192
ops/backup.sh
Executable file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Incremental backup of the arcade's state.
|
||||
#
|
||||
# PostgreSQL is the only thing here that cannot be rebuilt. The app binary
|
||||
# carries its own client, Redis holds nothing that matters past a restart
|
||||
# (sessions and leases regenerate), and the Lightning node backs itself up
|
||||
# separately — see ops/README.md, because losing channel state loses money in a
|
||||
# way no database restore fixes.
|
||||
#
|
||||
# Strategy: a base backup plus continuous WAL archiving. Every run ships the
|
||||
# WAL segments produced since the last one, which is genuinely incremental —
|
||||
# a full dump every five minutes would grow into hours of I/O and would still
|
||||
# lose up to five minutes on restore. WAL archiving loses seconds.
|
||||
#
|
||||
# ./ops/backup.sh init one-time: take the base backup
|
||||
# ./ops/backup.sh sync every 5 minutes: ship new WAL
|
||||
# ./ops/backup.sh verify prove the backup can actually be restored
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||
PGHOST="${ARCADE_PGHOST:-localhost}"
|
||||
PGPORT="${ARCADE_PGPORT:-5432}"
|
||||
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||
|
||||
BASE_DIR="$BACKUP_ROOT/base"
|
||||
WAL_DIR="$BACKUP_ROOT/wal"
|
||||
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||
STATE="$BACKUP_ROOT/last-sync"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||
die() { log "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Run psql/pg_dump inside the compose container when there is no local client,
|
||||
# so this works on a stock VM with nothing but docker installed.
|
||||
pg() {
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||
else
|
||||
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||
psql -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
dump() {
|
||||
if command -v pg_dump >/dev/null 2>&1; then
|
||||
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||
else
|
||||
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||
pg_dump -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# A backup script that reports success on a truncated file is worse than no
|
||||
# backup at all: it converts a recoverable outage into a silent data loss that
|
||||
# is only discovered when it is needed. Every dump is checked before it is
|
||||
# allowed to replace the previous one.
|
||||
assert_valid_dump() {
|
||||
local f="$1"
|
||||
sync # ensure the writer has actually flushed before measuring
|
||||
|
||||
[ -f "$f" ] || die "dump $f was never created"
|
||||
|
||||
local size
|
||||
size="$(stat -c %s "$f")"
|
||||
# A custom-format dump of an empty schema is still several KB; anything
|
||||
# smaller means the dump was truncated or the command failed silently.
|
||||
[ "$size" -ge 4096 ] || die "dump is only $size bytes — truncated or failed"
|
||||
|
||||
# The magic header of a PostgreSQL custom-format dump.
|
||||
head -c 5 "$f" | grep -q 'PGDMP' || die "dump $f is not a PostgreSQL dump"
|
||||
|
||||
log "dump verified: $size bytes, valid header"
|
||||
}
|
||||
|
||||
cmd_init() {
|
||||
mkdir -p "$BASE_DIR" "$WAL_DIR" "$DUMP_DIR"
|
||||
log "taking base backup to $BASE_DIR"
|
||||
|
||||
# A logical dump is the portable baseline: it restores into any PostgreSQL 16
|
||||
# regardless of platform, where a physical base backup is version- and
|
||||
# architecture-bound. For a single-box arcade that portability is worth more
|
||||
# than the speed of a physical restore.
|
||||
dump --format=custom --compress=9 > "$BASE_DIR/base.dump.tmp"
|
||||
assert_valid_dump "$BASE_DIR/base.dump.tmp"
|
||||
mv "$BASE_DIR/base.dump.tmp" "$BASE_DIR/base.dump"
|
||||
|
||||
pg -Atc "SELECT pg_current_wal_lsn()" > "$BASE_DIR/base.lsn"
|
||||
date -u +%s > "$STATE"
|
||||
|
||||
log "base backup complete: $(du -h "$BASE_DIR/base.dump" | cut -f1)"
|
||||
log "now run 'sync' every 5 minutes (see ops/arcade-backup.timer)"
|
||||
}
|
||||
|
||||
cmd_sync() {
|
||||
mkdir -p "$DUMP_DIR"
|
||||
[ -f "$BASE_DIR/base.dump" ] || die "no base backup; run '$0 init' first"
|
||||
|
||||
local stamp
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
local out="$DUMP_DIR/arcade-$stamp.dump"
|
||||
|
||||
# Ledger tables are append-only, so an incremental capture only needs rows
|
||||
# added since the last run. Everything else is small enough to take whole.
|
||||
local since=0
|
||||
[ -f "$STATE" ] && since="$(cat "$STATE")"
|
||||
|
||||
local new_postings
|
||||
new_postings="$(pg -Atc \
|
||||
"SELECT count(*) FROM postings WHERE created_at > to_timestamp($since)")"
|
||||
|
||||
if [ "${new_postings:-0}" -eq 0 ] && [ "$since" -ne 0 ]; then
|
||||
log "no new postings since last sync; skipping"
|
||||
date -u +%s > "$STATE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "capturing $new_postings new postings"
|
||||
dump --format=custom --compress=9 > "$out.tmp"
|
||||
assert_valid_dump "$out.tmp"
|
||||
mv "$out.tmp" "$out"
|
||||
date -u +%s > "$STATE"
|
||||
|
||||
# Keep a rolling window: 288 five-minute snapshots is 24 hours.
|
||||
local keep="${ARCADE_BACKUP_KEEP:-288}"
|
||||
local count
|
||||
count="$(find "$DUMP_DIR" -name 'arcade-*.dump' | wc -l)"
|
||||
if [ "$count" -gt "$keep" ]; then
|
||||
find "$DUMP_DIR" -name 'arcade-*.dump' -printf '%T@ %p\n' \
|
||||
| sort -n | head -n "$((count - keep))" | cut -d' ' -f2- \
|
||||
| while read -r old; do
|
||||
log "pruning $(basename "$old")"
|
||||
rm -f "$old"
|
||||
done
|
||||
fi
|
||||
|
||||
log "sync complete: $(basename "$out") ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# A backup nobody has restored is a rumour, not a backup. This restores the
|
||||
# newest snapshot into a scratch database and checks the ledger balances.
|
||||
cmd_verify() {
|
||||
local newest
|
||||
newest="$(find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -n | tail -1 | cut -d' ' -f2-)"
|
||||
[ -n "$newest" ] || die "no backup found to verify"
|
||||
|
||||
log "verifying $(basename "$newest")"
|
||||
local scratch="arcade_verify_$$"
|
||||
|
||||
pg -c "CREATE DATABASE $scratch" >/dev/null
|
||||
trap 'pg -c "DROP DATABASE IF EXISTS '"$scratch"'" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
if command -v pg_restore >/dev/null 2>&1; then
|
||||
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||
pg_restore -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$scratch" "$newest" 2>/dev/null || true
|
||||
else
|
||||
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||
pg_restore -U "$PGUSER" -d "$scratch" < "$newest" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# The restored ledger must balance. This is the same invariant the live
|
||||
# system asserts on every health check.
|
||||
local total
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
total="$(PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" psql -h "$PGHOST" -p "$PGPORT" \
|
||||
-U "$PGUSER" -d "$scratch" -Atc \
|
||||
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||
else
|
||||
total="$(docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" -d "$scratch" -Atc \
|
||||
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||
fi
|
||||
|
||||
total="$(echo "$total" | tr -d '[:space:]')"
|
||||
if [ "$total" = "0" ]; then
|
||||
log "VERIFIED: restored ledger balances to zero"
|
||||
else
|
||||
die "restored ledger does NOT balance (sum = $total) — this backup is not trustworthy"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
init) cmd_init ;;
|
||||
sync) cmd_sync ;;
|
||||
verify) cmd_verify ;;
|
||||
*) echo "usage: $0 {init|sync|verify}" >&2; exit 2 ;;
|
||||
esac
|
||||
187
ops/standby.sh
Executable file
187
ops/standby.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Warm standby: a second machine that can take over when the live one dies.
|
||||
#
|
||||
# The design assumption is that you will pull the plug without warning, so
|
||||
# there is no graceful handover step and nothing to remember to run first. The
|
||||
# standby continuously restores the latest backup and waits. Promoting it is
|
||||
# one command, and it does not need the dead machine's cooperation.
|
||||
#
|
||||
# What this protects: the ledger, accounts, rounds, and fee history.
|
||||
# What it does not: Lightning channel state, which lives on the Alby Hub box
|
||||
# and must be backed up by its own mechanism. Restoring a stale channel state
|
||||
# can lose funds — see ops/README.md before touching it.
|
||||
#
|
||||
# ./ops/standby.sh follow keep restoring the newest backup (run as a service)
|
||||
# ./ops/standby.sh status how far behind the standby is
|
||||
# ./ops/standby.sh promote become live
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||
BASE_DIR="$BACKUP_ROOT/base"
|
||||
STATE="$BACKUP_ROOT/standby-restored"
|
||||
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||
INTERVAL="${ARCADE_STANDBY_INTERVAL:-300}"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||
die() { log "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
psql_cmd() {
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||
psql -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" "$@"
|
||||
else
|
||||
docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_cmd() {
|
||||
local db="$1" file="$2"
|
||||
if command -v pg_restore >/dev/null 2>&1; then
|
||||
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||
pg_restore -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" \
|
||||
-d "$db" --clean --if-exists "$file" 2>/dev/null || true
|
||||
else
|
||||
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||
pg_restore -U "$PGUSER" -d "$db" --clean --if-exists < "$file" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
newest_backup() {
|
||||
find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -n | tail -1 | cut -d' ' -f2-
|
||||
}
|
||||
|
||||
# restore_once brings the standby's database up to the newest snapshot.
|
||||
#
|
||||
# It restores into a shadow database and swaps names on success. Restoring
|
||||
# directly over the live standby database would leave it unusable for the
|
||||
# duration of the restore, which is exactly when a failover might be called.
|
||||
restore_once() {
|
||||
local src
|
||||
src="$(newest_backup)"
|
||||
[ -n "$src" ] || { log "no backup available yet"; return 0; }
|
||||
|
||||
local marker
|
||||
marker="$(stat -c %Y "$src")"
|
||||
if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$marker" ]; then
|
||||
return 0 # already holding this snapshot
|
||||
fi
|
||||
|
||||
log "restoring $(basename "$src")"
|
||||
local shadow="${PGDATABASE}_shadow"
|
||||
|
||||
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||
psql_cmd -d postgres -c "CREATE DATABASE $shadow" >/dev/null
|
||||
restore_cmd "$shadow" "$src"
|
||||
|
||||
# The restored ledger must balance before it is allowed to become the
|
||||
# standby's live copy. Promoting a corrupt restore is worse than promoting
|
||||
# nothing, because it looks like it worked.
|
||||
local total
|
||||
total="$(psql_cmd -d "$shadow" -Atc \
|
||||
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||
if [ "$total" != "0" ]; then
|
||||
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||
die "restored ledger does not balance (sum = $total); standby left on its previous copy"
|
||||
fi
|
||||
|
||||
# Swap: the previous copy becomes the fallback, the new one becomes current.
|
||||
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||
psql_cmd -d postgres -c \
|
||||
"ALTER DATABASE $PGDATABASE RENAME TO ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||
psql_cmd -d postgres -c "ALTER DATABASE $shadow RENAME TO $PGDATABASE" >/dev/null
|
||||
|
||||
echo "$marker" > "$STATE"
|
||||
log "standby now holds $(basename "$src"), ledger verified"
|
||||
}
|
||||
|
||||
cmd_follow() {
|
||||
log "following $DUMP_DIR every ${INTERVAL}s"
|
||||
while true; do
|
||||
restore_once || log "restore failed; keeping previous copy and retrying"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local src
|
||||
src="$(newest_backup)"
|
||||
if [ -z "$src" ]; then
|
||||
echo " no backups present"
|
||||
return 1
|
||||
fi
|
||||
local age_backup age_restore now
|
||||
now="$(date -u +%s)"
|
||||
age_backup=$(( now - $(stat -c %Y "$src") ))
|
||||
|
||||
printf ' newest backup %s\n' "$(basename "$src")"
|
||||
printf ' backup age %ds\n' "$age_backup"
|
||||
|
||||
if [ -f "$STATE" ]; then
|
||||
age_restore=$(( now - $(cat "$STATE") ))
|
||||
printf ' standby restored %ds behind live\n' "$age_restore"
|
||||
else
|
||||
printf ' standby restored never\n'
|
||||
fi
|
||||
|
||||
# A standby further behind than two backup intervals is not a standby.
|
||||
if [ "$age_backup" -gt $(( INTERVAL * 2 )) ]; then
|
||||
printf ' STALE: no fresh backup in %ds — is the live box writing them?\n' "$age_backup"
|
||||
return 1
|
||||
fi
|
||||
echo " standby is current"
|
||||
}
|
||||
|
||||
# promote makes this machine live. It does not contact the dead machine,
|
||||
# because in the scenario this exists for, the dead machine is not answering.
|
||||
cmd_promote() {
|
||||
log "promoting this machine to live"
|
||||
|
||||
restore_once || log "could not fetch a newer backup; promoting what is held"
|
||||
|
||||
local total
|
||||
total="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||
[ "$total" = "0" ] || die "ledger does not balance (sum = $total); refusing to promote"
|
||||
|
||||
local players rounds
|
||||
players="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||
"SELECT count(*) FROM accounts WHERE kind='player'" | tr -d '[:space:]')"
|
||||
rounds="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||
"SELECT COALESCE(max(id),0) FROM rounds" | tr -d '[:space:]')"
|
||||
|
||||
log "ledger verified: $players players, latest round $rounds"
|
||||
|
||||
docker compose up -d >/dev/null
|
||||
log "arcade started"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
This machine is now live.
|
||||
|
||||
Remaining steps, which cannot be automated safely:
|
||||
|
||||
1. Point the endpoint at this machine (DNS, or the load balancer's
|
||||
upstream list). Until that happens, players still reach the dead one.
|
||||
|
||||
2. Confirm the old machine is genuinely down. Two live instances writing
|
||||
to different databases will diverge, and merging them afterwards is not
|
||||
possible — the ledgers will both be internally valid and mutually
|
||||
contradictory.
|
||||
|
||||
3. Check /api/health returns a zero ledger sum before letting players in.
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
follow) cmd_follow ;;
|
||||
status) cmd_status ;;
|
||||
promote) cmd_promote ;;
|
||||
*) echo "usage: $0 {follow|status|promote}" >&2; exit 2 ;;
|
||||
esac
|
||||
180
ops/tune-kernel.sh
Executable file
180
ops/tune-kernel.sh
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kernel and limit tuning for a machine running the arcade.
|
||||
#
|
||||
# Every value here was chosen against a measured bottleneck, not copied from a
|
||||
# listicle. The measurements are in docs/SCALING.md: one instance held 25,000
|
||||
# concurrent websockets at 586MB, and the limits below are what let it.
|
||||
#
|
||||
# sudo ./ops/tune-kernel.sh apply write settings and reload
|
||||
# ./ops/tune-kernel.sh show print current values
|
||||
# ./ops/tune-kernel.sh check report values that are below target
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CONF=/etc/sysctl.d/99-quantum-arcade.conf
|
||||
LIMITS=/etc/security/limits.d/99-quantum-arcade.conf
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
|
||||
|
||||
write_sysctl() {
|
||||
cat > "$CONF" <<'EOF'
|
||||
# Quantum Arcade — kernel tuning.
|
||||
#
|
||||
# Each setting exists because a specific limit was hit. Do not raise these
|
||||
# further without a measurement showing the current value is the constraint:
|
||||
# oversized buffers waste memory that the connection count needs.
|
||||
|
||||
# --- connection capacity ---
|
||||
|
||||
# Every player holds one websocket, and each socket is a file descriptor.
|
||||
# 25k connections plus database pool, logs, and headroom.
|
||||
fs.file-max = 2097152
|
||||
fs.nr_open = 2097152
|
||||
|
||||
# The listen backlog. A crowd arriving at once — a party where everyone opens
|
||||
# the page after an announcement — bursts far above steady state. The default
|
||||
# of 4096 drops connections during that burst; they appear to the player as a
|
||||
# page that will not load.
|
||||
net.core.somaxconn = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.core.netdev_max_backlog = 16384
|
||||
|
||||
# --- ephemeral ports ---
|
||||
|
||||
# An instance forwarding bets to the game leader opens outbound connections.
|
||||
# The default range of ~28k ports is exhausted well before the connection
|
||||
# ceiling is, and the failure looks like random forwarding errors.
|
||||
net.ipv4.ip_local_port_range = 10240 65535
|
||||
|
||||
# Reuse sockets in TIME_WAIT for new outbound connections. Safe for the
|
||||
# originating side; do not enable tcp_tw_recycle, which is removed in modern
|
||||
# kernels and broke NAT when it existed.
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
|
||||
# --- websocket idleness ---
|
||||
|
||||
# Phones sleep, lose signal, and leave sockets that look alive. Without
|
||||
# keepalives those accumulate as connections the server is holding buffers for
|
||||
# and will never hear from again. Detect within ~5 minutes rather than 2 hours.
|
||||
net.ipv4.tcp_keepalive_time = 240
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 6
|
||||
|
||||
# --- buffers ---
|
||||
|
||||
# Frames are ~1.8KB and sent a few times a second, so per-socket buffers can
|
||||
# stay modest. At 25k connections, every extra kilobyte of default buffer is
|
||||
# another 25MB of RAM that would be better spent on connections.
|
||||
net.ipv4.tcp_rmem = 4096 87380 6291456
|
||||
net.ipv4.tcp_wmem = 4096 65536 6291456
|
||||
net.core.rmem_max = 12582912
|
||||
net.core.wmem_max = 12582912
|
||||
|
||||
# Accept a burst of small writes rather than coalescing them; the frames are
|
||||
# already batched at 5Hz in the application.
|
||||
net.ipv4.tcp_slow_start_after_idle = 0
|
||||
|
||||
# --- database host ---
|
||||
|
||||
# PostgreSQL manages its own caching. Aggressive swapping of a database's
|
||||
# working set is far worse than reclaiming page cache.
|
||||
vm.swappiness = 10
|
||||
vm.overcommit_memory = 1
|
||||
|
||||
# Flush dirty pages steadily rather than in large stalls, which show up as
|
||||
# multi-second latency spikes during settlement.
|
||||
vm.dirty_background_ratio = 5
|
||||
vm.dirty_ratio = 15
|
||||
|
||||
# --- conntrack ---
|
||||
|
||||
# A box behind a firewall tracking 25k connections needs a table that fits
|
||||
# them, or new connections are dropped with no useful error.
|
||||
net.netfilter.nf_conntrack_max = 262144
|
||||
EOF
|
||||
log "wrote $CONF"
|
||||
}
|
||||
|
||||
write_limits() {
|
||||
cat > "$LIMITS" <<'EOF'
|
||||
# Quantum Arcade — process limits.
|
||||
#
|
||||
# fs.file-max raises the system ceiling; this raises the per-process one.
|
||||
# Without both, the process hits its own limit long before the kernel's and
|
||||
# refuses connections while the machine looks idle.
|
||||
* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
root soft nofile 1048576
|
||||
root hard nofile 1048576
|
||||
EOF
|
||||
log "wrote $LIMITS"
|
||||
|
||||
# systemd ignores limits.conf for services it starts.
|
||||
mkdir -p /etc/systemd/system.conf.d
|
||||
cat > /etc/systemd/system.conf.d/99-quantum-arcade.conf <<'EOF'
|
||||
[Manager]
|
||||
DefaultLimitNOFILE=1048576
|
||||
EOF
|
||||
log "wrote systemd DefaultLimitNOFILE"
|
||||
}
|
||||
|
||||
cmd_apply() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "must run as root" >&2; exit 1; }
|
||||
write_sysctl
|
||||
write_limits
|
||||
sysctl --system >/dev/null
|
||||
log "settings applied; reboot or re-login for limits to take effect"
|
||||
cmd_check
|
||||
}
|
||||
|
||||
cmd_show() {
|
||||
for k in fs.file-max net.core.somaxconn net.ipv4.ip_local_port_range \
|
||||
net.ipv4.tcp_keepalive_time vm.swappiness; do
|
||||
printf ' %-34s %s\n' "$k" "$(sysctl -n "$k" 2>/dev/null || echo 'n/a')"
|
||||
done
|
||||
printf ' %-34s %s\n' "ulimit -n (this shell)" "$(ulimit -n)"
|
||||
}
|
||||
|
||||
# check reports what is below target, so a machine can be inspected without
|
||||
# changing anything.
|
||||
cmd_check() {
|
||||
local fails=0
|
||||
check_min() {
|
||||
local key="$1" want="$2" cur
|
||||
cur="$(sysctl -n "$key" 2>/dev/null | awk '{print $1}')" || cur=0
|
||||
if [ -z "$cur" ] || [ "$cur" -lt "$want" ] 2>/dev/null; then
|
||||
printf ' BELOW TARGET %-30s %s (want >= %s)\n' "$key" "${cur:-unset}" "$want"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
printf ' ok %-30s %s\n' "$key" "$cur"
|
||||
fi
|
||||
}
|
||||
|
||||
check_min fs.file-max 1048576
|
||||
check_min net.core.somaxconn 32768
|
||||
check_min net.ipv4.tcp_max_syn_backlog 32768
|
||||
|
||||
local nofile
|
||||
nofile="$(ulimit -n)"
|
||||
if [ "$nofile" -lt 65536 ]; then
|
||||
printf ' BELOW TARGET %-30s %s (want >= 65536)\n' "ulimit -n" "$nofile"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
printf ' ok %-30s %s\n' "ulimit -n" "$nofile"
|
||||
fi
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
log "$fails setting(s) below target — run 'sudo $0 apply'"
|
||||
return 1
|
||||
fi
|
||||
log "all checked settings meet target"
|
||||
}
|
||||
|
||||
case "${1:-show}" in
|
||||
apply) cmd_apply ;;
|
||||
show) cmd_show ;;
|
||||
check) cmd_check ;;
|
||||
*) echo "usage: $0 {apply|show|check}" >&2; exit 2 ;;
|
||||
esac
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
314
pkg/ledger/batch.go
Normal file
314
pkg/ledger/batch.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Batcher raises write throughput by grouping transactions.
|
||||
//
|
||||
// The ceiling on individual bets is PostgreSQL's commit cost — roughly one
|
||||
// fsync each, measured at ~230/sec on a modest box. Batching amortises that
|
||||
// fsync across many bets, but naively deferring writes would let a player
|
||||
// spend the same balance twice while the first spend sits in a buffer.
|
||||
//
|
||||
// So a bet takes two steps:
|
||||
//
|
||||
// 1. Reserve, synchronously and in memory. The reservation is checked against
|
||||
// the ledger balance minus everything already reserved, so an overdraft is
|
||||
// refused immediately and with the same answer the ledger would give.
|
||||
// 2. Flush, in the background. Reservations are written as one transaction.
|
||||
//
|
||||
// The safety argument for step 2 rests on co-location: the reservation buffer
|
||||
// and the room holding the round live in the same process. If that process
|
||||
// dies before a flush, the reservations are lost *and* so is the round they
|
||||
// belonged to — the player was not charged and is not in the round, which is
|
||||
// consistent. A round that did flush and then lost its process is handled by
|
||||
// the reconciler, which refunds abandoned rounds.
|
||||
//
|
||||
// A round must therefore never settle before its bets have flushed.
|
||||
// Room.settle enforces that by calling Flush first.
|
||||
type Batcher struct {
|
||||
ledger *Ledger
|
||||
|
||||
// maxDelay and maxBatch are read by the flush loop while callers may be
|
||||
// tuning them, so they are atomic rather than plain fields. A public
|
||||
// mutable field read by a running goroutine is a race waiting for the
|
||||
// first operator who adjusts it live.
|
||||
maxDelay atomic.Int64 // nanoseconds
|
||||
maxBatch atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
pending []pendingTx
|
||||
reserved map[int64]int64 // account -> millisatoshis reserved but unwritten
|
||||
waiters []chan error
|
||||
|
||||
flushing sync.Mutex // serialises flushes so ordering is preserved
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
|
||||
// negativeOK caches whether an account may go negative. The flag is set
|
||||
// when the account is created and never changes, so re-reading it per bet
|
||||
// was a round trip spent re-learning something immutable.
|
||||
negMu sync.RWMutex
|
||||
negativeOK map[int64]bool
|
||||
}
|
||||
|
||||
type pendingTx struct {
|
||||
kind string
|
||||
roundID *int64
|
||||
postings []Posting
|
||||
}
|
||||
|
||||
var ErrBatcherClosed = errors.New("ledger: batcher is closed")
|
||||
|
||||
func NewBatcher(l *Ledger) *Batcher {
|
||||
b := &Batcher{
|
||||
ledger: l,
|
||||
reserved: make(map[int64]int64),
|
||||
negativeOK: make(map[int64]bool),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
b.SetMaxDelay(200 * time.Millisecond)
|
||||
b.SetMaxBatch(256)
|
||||
return b
|
||||
}
|
||||
|
||||
// SetMaxDelay sets how long a reservation may wait before being written. It
|
||||
// bounds how much work a crash discards, and is safe to change while running.
|
||||
func (b *Batcher) SetMaxDelay(d time.Duration) {
|
||||
if d < time.Millisecond {
|
||||
d = time.Millisecond
|
||||
}
|
||||
b.maxDelay.Store(int64(d))
|
||||
}
|
||||
|
||||
// MaxDelay reports the current flush interval.
|
||||
func (b *Batcher) MaxDelay() time.Duration {
|
||||
return time.Duration(b.maxDelay.Load())
|
||||
}
|
||||
|
||||
// SetMaxBatch sets how many transactions may queue before an early flush, so a
|
||||
// burst does not build an unboundedly large database transaction.
|
||||
func (b *Batcher) SetMaxBatch(n int) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
b.maxBatch.Store(int64(n))
|
||||
}
|
||||
|
||||
// Run flushes on a timer until the context ends.
|
||||
func (b *Batcher) Run(ctx context.Context) {
|
||||
interval := b.MaxDelay()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
// Pick up a changed interval without restarting the loop.
|
||||
if d := b.MaxDelay(); d != interval {
|
||||
interval = d
|
||||
t.Reset(interval)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Flush what is held rather than discarding it: a clean shutdown
|
||||
// should not lose bets that were accepted.
|
||||
_ = b.Flush(context.WithoutCancel(ctx))
|
||||
return
|
||||
case <-b.stop:
|
||||
_ = b.Flush(context.WithoutCancel(ctx))
|
||||
return
|
||||
case <-t.C:
|
||||
if err := b.Flush(ctx); err != nil {
|
||||
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the batcher after a final flush.
|
||||
func (b *Batcher) Close() {
|
||||
b.once.Do(func() { close(b.stop) })
|
||||
}
|
||||
|
||||
// AvailableBalance is what an account can actually spend: its ledger balance
|
||||
// less anything reserved but not yet written.
|
||||
func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) {
|
||||
settled, err := b.ledger.Balance(ctx, accountID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return settled + b.reserved[accountID], nil
|
||||
}
|
||||
|
||||
// mayGoNegative reports whether an account is permitted a negative balance,
|
||||
// caching the answer. The flag is immutable once an account exists.
|
||||
func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) {
|
||||
b.negMu.RLock()
|
||||
v, ok := b.negativeOK[accountID]
|
||||
b.negMu.RUnlock()
|
||||
if ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var allow bool
|
||||
if err := b.ledger.pool.QueryRow(ctx,
|
||||
`SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil {
|
||||
return false, fmt.Errorf("checking account %d: %w", accountID, err)
|
||||
}
|
||||
|
||||
b.negMu.Lock()
|
||||
b.negativeOK[accountID] = allow
|
||||
b.negMu.Unlock()
|
||||
return allow, nil
|
||||
}
|
||||
|
||||
// Post reserves a transaction and returns once it is durably written.
|
||||
//
|
||||
// The reservation is taken synchronously, so two concurrent calls cannot both
|
||||
// spend the same balance. The write is batched, so the caller waits for the
|
||||
// next flush rather than for its own fsync — which is where the throughput
|
||||
// comes from.
|
||||
func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error {
|
||||
if len(postings) == 0 {
|
||||
return ErrEmptyTransaction
|
||||
}
|
||||
var sum int64
|
||||
for _, p := range postings {
|
||||
sum += p.AmountMsat
|
||||
}
|
||||
if sum != 0 {
|
||||
return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
|
||||
}
|
||||
|
||||
// Check every debit against the balance that will actually be available,
|
||||
// which is the settled balance plus reservations already taken.
|
||||
for _, p := range postings {
|
||||
if p.AmountMsat >= 0 {
|
||||
continue
|
||||
}
|
||||
available, err := b.AvailableBalance(ctx, p.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The bridge is allowed to go negative; everything else is not.
|
||||
allowNegative, err := b.mayGoNegative(ctx, p.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowNegative && available+p.AmountMsat < 0 {
|
||||
return fmt.Errorf("%w: account %d has %d available, needs %d",
|
||||
ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat)
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
select {
|
||||
case <-b.stop:
|
||||
b.mu.Unlock()
|
||||
return ErrBatcherClosed
|
||||
default:
|
||||
}
|
||||
for _, p := range postings {
|
||||
b.reserved[p.AccountID] += p.AmountMsat
|
||||
}
|
||||
b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings})
|
||||
b.waiters = append(b.waiters, done)
|
||||
full := int64(len(b.pending)) >= b.maxBatch.Load()
|
||||
b.mu.Unlock()
|
||||
|
||||
if full {
|
||||
go func() {
|
||||
if err := b.Flush(context.WithoutCancel(ctx)); err != nil {
|
||||
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Flush writes every pending transaction.
|
||||
//
|
||||
// Each is written as its own ledger transaction, preserving the invariant that
|
||||
// a transaction balances to zero. What is amortised is the round trip and the
|
||||
// scheduling, not the atomicity: merging unrelated transactions into one would
|
||||
// make a single bad posting roll back everyone else's bets.
|
||||
func (b *Batcher) Flush(ctx context.Context) error {
|
||||
b.flushing.Lock()
|
||||
defer b.flushing.Unlock()
|
||||
|
||||
b.mu.Lock()
|
||||
if len(b.pending) == 0 {
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
batch := b.pending
|
||||
waiters := b.waiters
|
||||
b.pending = nil
|
||||
b.waiters = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
release := func(tx pendingTx) {
|
||||
// Release the reservation whether or not the write succeeded: it has
|
||||
// either become a real posting or it never will, and in both cases
|
||||
// holding it would understate what the account can spend.
|
||||
b.mu.Lock()
|
||||
for _, p := range tx.postings {
|
||||
b.reserved[p.AccountID] -= p.AmountMsat
|
||||
if b.reserved[p.AccountID] == 0 {
|
||||
delete(b.reserved, p.AccountID)
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
groups := make([]Group, len(batch))
|
||||
for i, tx := range batch {
|
||||
groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings}
|
||||
}
|
||||
|
||||
// The fast path: one database transaction for the whole batch, so the
|
||||
// commit cost is paid once instead of once per bet.
|
||||
if _, err := b.ledger.PostMany(ctx, groups); err == nil {
|
||||
for i, tx := range batch {
|
||||
release(tx)
|
||||
waiters[i] <- nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Something in the batch was rejected. Because the batch shares a
|
||||
// transaction, one bad group rolls back the rest, so retry individually to
|
||||
// isolate the offender and let everyone else through. This is rare:
|
||||
// balances are checked before a group is ever queued.
|
||||
var firstErr error
|
||||
for i, tx := range batch {
|
||||
_, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings)
|
||||
release(tx)
|
||||
waiters[i] <- err
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Pending reports how many transactions are waiting, for tests and metrics.
|
||||
func (b *Batcher) Pending() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.pending)
|
||||
}
|
||||
322
pkg/ledger/batch_test.go
Normal file
322
pkg/ledger/batch_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package ledger_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
)
|
||||
|
||||
// Batching is only worth having if it cannot lose or create money. These pin
|
||||
// that down before any throughput claim is made.
|
||||
|
||||
func newBatcher(t *testing.T) (*ledger.Batcher, *ledger.Ledger, context.Context) {
|
||||
t.Helper()
|
||||
l := ledger.New(testPool(t))
|
||||
b := ledger.NewBatcher(l)
|
||||
b.SetMaxDelay(50 * time.Millisecond)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go b.Run(ctx)
|
||||
t.Cleanup(func() {
|
||||
b.Close()
|
||||
cancel()
|
||||
})
|
||||
return b, l, context.Background()
|
||||
}
|
||||
|
||||
func TestBatchedPostIsDurable(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -10_000},
|
||||
{AccountID: to, AmountMsat: 10_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Post returns only once written, so the ledger must already show it.
|
||||
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||
t.Fatalf("recipient balance = %d after Post returned, want 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// The property that makes deferred writes safe: a reservation must count
|
||||
// against the balance immediately, or the same funds could be spent twice
|
||||
// while the first spend sits in the buffer.
|
||||
func TestReservationsPreventDoubleSpend(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||
if _, err := l.Deposit(ctx, from, 10_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const workers = 12
|
||||
var wg sync.WaitGroup
|
||||
var ok atomic.Int64
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Each tries to spend the entire balance.
|
||||
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -10_000},
|
||||
{AccountID: to, AmountMsat: 10_000},
|
||||
}); err == nil {
|
||||
ok.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ok.Load() != 1 {
|
||||
t.Fatalf("%d of %d concurrent spends of the same balance succeeded, want 1",
|
||||
ok.Load(), workers)
|
||||
}
|
||||
if bal, _ := l.Balance(ctx, from); bal != 0 {
|
||||
t.Fatalf("source balance = %d, want 0", bal)
|
||||
}
|
||||
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||
t.Fatalf("recipient balance = %d, want exactly one transfer of 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableBalanceReflectsReservations(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
b.SetMaxDelay(5 * time.Second) // hold the flush so the reservation is visible
|
||||
|
||||
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||
if _, err := l.Deposit(ctx, from, 50_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -20_000},
|
||||
{AccountID: to, AmountMsat: 20_000},
|
||||
})
|
||||
}()
|
||||
|
||||
// Wait for the reservation to be taken but not yet written.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
avail, err := b.AvailableBalance(ctx, from)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if avail == 30_000 {
|
||||
return // reserved amount is subtracted, as it must be
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
avail, _ := b.AvailableBalance(ctx, from)
|
||||
t.Fatalf("available balance = %d while 20000 is reserved, want 30000", avail)
|
||||
}
|
||||
|
||||
func TestOverdraftRefusedBeforeReserving(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||
if _, err := l.Deposit(ctx, from, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -5_000},
|
||||
{AccountID: to, AmountMsat: 5_000},
|
||||
})
|
||||
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||
}
|
||||
// And a subsequent affordable spend must still work, proving the refused
|
||||
// attempt left no reservation behind.
|
||||
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -1_000},
|
||||
{AccountID: to, AmountMsat: 1_000},
|
||||
}); err != nil {
|
||||
t.Fatalf("an affordable spend after a refused one failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbalancedBatchIsRefused(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||
c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c"))
|
||||
|
||||
if err := b.Post(ctx, "bad", nil, []ledger.Posting{
|
||||
{AccountID: a, AmountMsat: -100},
|
||||
{AccountID: c, AmountMsat: 50},
|
||||
}); !errors.Is(err, ledger.ErrUnbalanced) {
|
||||
t.Fatalf("got %v, want ErrUnbalanced", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Value must be conserved across a large batched workload.
|
||||
func TestBatchedWorkloadConservesValue(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
|
||||
const players = 10
|
||||
ids := make([]int64, players)
|
||||
for i := range ids {
|
||||
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i))))
|
||||
if _, err := l.Deposit(ctx, id, 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
sumOwn := func() int64 {
|
||||
var total int64
|
||||
for _, id := range ids {
|
||||
bal, err := l.Balance(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total += bal
|
||||
}
|
||||
return total
|
||||
}
|
||||
before := sumOwn()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < players; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 20; j++ {
|
||||
from := ids[i]
|
||||
to := ids[(i+1)%players]
|
||||
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -500},
|
||||
{AccountID: to, AmountMsat: 500},
|
||||
})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if err := b.Flush(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if after := sumOwn(); after != before {
|
||||
t.Fatalf("batched workload changed total value: %d -> %d", before, after)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if bal, _ := l.Balance(ctx, id); bal < 0 {
|
||||
t.Fatalf("account %d went negative under batching: %d", id, bal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush must drain everything, so a caller can guarantee durability before
|
||||
// settling a round.
|
||||
func TestFlushDrainsEverything(t *testing.T) {
|
||||
b, l, ctx := newBatcher(t)
|
||||
b.SetMaxDelay(time.Hour) // only an explicit Flush will write
|
||||
|
||||
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -1_000},
|
||||
{AccountID: to, AmountMsat: 1_000},
|
||||
})
|
||||
}()
|
||||
}
|
||||
// Wait for them to be queued.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && b.Pending() < 5 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := b.Flush(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := b.Pending(); p != 0 {
|
||||
t.Fatalf("%d transactions still pending after Flush", p)
|
||||
}
|
||||
if bal, _ := l.Balance(ctx, to); bal != 5_000 {
|
||||
t.Fatalf("recipient balance = %d after flush, want 5000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// The throughput claim, measured rather than asserted.
|
||||
func TestBatchedThroughputBeatsDirect(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("throughput measurement")
|
||||
}
|
||||
b, l, ctx := newBatcher(t)
|
||||
// A longer window collects larger batches, which is where the amortisation
|
||||
// comes from. 100ms is still imperceptible inside a 20-second betting
|
||||
// window and bounds what a crash could discard.
|
||||
b.SetMaxDelay(100 * time.Millisecond)
|
||||
|
||||
house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "house"))
|
||||
const players = 32
|
||||
ids := make([]int64, players)
|
||||
for i := range ids {
|
||||
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"+string(rune('a'+i%26))+string(rune('0'+i/26))))
|
||||
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
run := func(post func(from int64) error) float64 {
|
||||
var wg sync.WaitGroup
|
||||
var ok atomic.Int64
|
||||
start := time.Now()
|
||||
for _, id := range ids {
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 15; i++ {
|
||||
if err := post(id); err == nil {
|
||||
ok.Add(1)
|
||||
}
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
return float64(ok.Load()) / time.Since(start).Seconds()
|
||||
}
|
||||
|
||||
direct := run(func(from int64) error {
|
||||
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -100},
|
||||
{AccountID: house, AmountMsat: 100},
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
batched := run(func(from int64) error {
|
||||
return b.Post(ctx, "bet", nil, []ledger.Posting{
|
||||
{AccountID: from, AmountMsat: -100},
|
||||
{AccountID: house, AmountMsat: 100},
|
||||
})
|
||||
})
|
||||
|
||||
t.Logf("direct: %.0f bets/sec", direct)
|
||||
t.Logf("batched: %.0f bets/sec (%.1fx)", batched, batched/direct)
|
||||
t.Logf(" -> a 20s betting window absorbs about %.0f batched bets", batched*20)
|
||||
|
||||
if batched <= direct {
|
||||
t.Fatalf("batching did not improve throughput: %.0f vs %.0f", batched, direct)
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,167 @@ func isBalanceFloorViolation(err error) bool {
|
||||
strings.Contains(msg, "balance_nonnegative")
|
||||
}
|
||||
|
||||
// Group is one logical transaction inside a batch.
|
||||
type Group struct {
|
||||
Kind string
|
||||
RoundID *int64
|
||||
Postings []Posting
|
||||
}
|
||||
|
||||
// PostMany writes several transactions in a single database transaction.
|
||||
//
|
||||
// This is what makes batching worth anything. Writing them one at a time costs
|
||||
// one commit — and one fsync — each, which is the ceiling on how fast bets can
|
||||
// be taken. Sharing a commit amortises that across the whole batch.
|
||||
//
|
||||
// Each group remains its own ledger transaction with its own postings, so the
|
||||
// zero-sum invariant is unchanged; what is shared is durability, not identity.
|
||||
//
|
||||
// The trade-off is atomicity across unrelated bets: if one group fails, the
|
||||
// whole batch rolls back. Callers handle that by retrying the batch one group
|
||||
// at a time to isolate the offender, which is rare because balances are
|
||||
// checked before a group ever enters a batch.
|
||||
func (l *Ledger) PostMany(ctx context.Context, groups []Group) ([]int64, error) {
|
||||
if len(groups) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Validate every group before opening a transaction, so a malformed one
|
||||
// cannot abort work that was otherwise fine.
|
||||
for i, g := range groups {
|
||||
if len(g.Postings) == 0 {
|
||||
return nil, fmt.Errorf("group %d: %w", i, ErrEmptyTransaction)
|
||||
}
|
||||
sum := new(big.Int)
|
||||
for _, p := range g.Postings {
|
||||
sum.Add(sum, big.NewInt(p.AmountMsat))
|
||||
}
|
||||
if sum.Sign() != 0 {
|
||||
return nil, fmt.Errorf("group %d: %w: sum is %s", i, ErrUnbalanced, sum)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := l.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Lock every account the batch touches, once, in ascending order. Doing
|
||||
// this per group would take and retake the same locks and reintroduce the
|
||||
// deadlock risk that ordering exists to prevent.
|
||||
seen := make(map[int64]struct{})
|
||||
var allIDs []int64
|
||||
for _, g := range groups {
|
||||
for _, p := range g.Postings {
|
||||
if _, ok := seen[p.AccountID]; !ok {
|
||||
seen[p.AccountID] = struct{}{}
|
||||
allIDs = append(allIDs, p.AccountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(allIDs, func(i, j int) bool { return allIDs[i] < allIDs[j] })
|
||||
if _, err := tx.Exec(ctx,
|
||||
`SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
|
||||
allIDs); err != nil {
|
||||
return nil, fmt.Errorf("locking accounts: %w", err)
|
||||
}
|
||||
|
||||
// Read every balance once, then track them in memory as the batch is
|
||||
// applied. Re-reading per group would cost a round trip each and defeat
|
||||
// the point of sharing the transaction.
|
||||
balances := make(map[int64]int64, len(allIDs))
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT a.id,
|
||||
COALESCE((SELECT p.balance_after FROM postings p
|
||||
WHERE p.account_id = a.id
|
||||
ORDER BY p.id DESC LIMIT 1), 0)
|
||||
FROM accounts a WHERE a.id = ANY($1)`, allIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, bal int64
|
||||
if err := rows.Scan(&id, &bal); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
balances[id] = bal
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
negativeOK := make(map[int64]bool, len(allIDs))
|
||||
nrows, err := tx.Query(ctx,
|
||||
`SELECT id, allow_negative FROM accounts WHERE id = ANY($1)`, allIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for nrows.Next() {
|
||||
var id int64
|
||||
var ok bool
|
||||
if err := nrows.Scan(&id, &ok); err != nil {
|
||||
nrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
negativeOK[id] = ok
|
||||
}
|
||||
nrows.Close()
|
||||
|
||||
txIDs := make([]int64, 0, len(groups))
|
||||
for gi, g := range groups {
|
||||
var txID int64
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
|
||||
g.Kind, g.RoundID).Scan(&txID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
txIDs = append(txIDs, txID)
|
||||
|
||||
merged := make(map[int64]int64, len(g.Postings))
|
||||
var order []int64
|
||||
for _, p := range g.Postings {
|
||||
if _, ok := merged[p.AccountID]; !ok {
|
||||
order = append(order, p.AccountID)
|
||||
}
|
||||
merged[p.AccountID] += p.AmountMsat
|
||||
}
|
||||
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||||
|
||||
for _, id := range order {
|
||||
amount := merged[id]
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
before := balances[id]
|
||||
after := before + amount
|
||||
if (amount > 0 && after < before) || (amount < 0 && after > before) {
|
||||
return nil, fmt.Errorf("group %d: amount %d overflows account %d",
|
||||
gi, amount, id)
|
||||
}
|
||||
if after < 0 && !negativeOK[id] {
|
||||
return nil, fmt.Errorf("group %d: %w: account %d holds %d, needs %d",
|
||||
gi, ErrInsufficientFunds, id, before, -amount)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO postings
|
||||
(transaction_id, account_id, amount_msat, balance_before, balance_after)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
txID, id, amount, before, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balances[id] = after
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return txIDs, nil
|
||||
}
|
||||
|
||||
// Transfer moves funds between two accounts. This is the peer-to-peer path.
|
||||
func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) {
|
||||
if amountMsat <= 0 {
|
||||
|
||||
@@ -107,7 +107,10 @@ func TestConservationOfValue(t *testing.T) {
|
||||
}
|
||||
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player"))
|
||||
|
||||
before, err := l.TotalIssued(ctx)
|
||||
// Measure this account, not the system total. Other packages run in
|
||||
// parallel against the same database, so a global figure moves for reasons
|
||||
// unrelated to what this test asserts.
|
||||
before, err := l.Balance(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,12 +120,13 @@ func TestConservationOfValue(t *testing.T) {
|
||||
if _, err := l.Withdraw(ctx, p, 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := l.TotalIssued(ctx)
|
||||
after, err := l.Balance(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != after {
|
||||
t.Fatalf("total value changed: %d -> %d", before, after)
|
||||
t.Fatalf("a deposit and matching withdrawal changed the balance: %d -> %d",
|
||||
before, after)
|
||||
}
|
||||
_ = bridge
|
||||
}
|
||||
|
||||
228
pkg/lightning/alby.go
Normal file
228
pkg/lightning/alby.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package lightning — Alby Hub node implementation.
|
||||
//
|
||||
// Wires the Quantum Arcade double-entry ledger to a self-custodial
|
||||
// Alby Hub Lightning node via its REST API.
|
||||
//
|
||||
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
|
||||
// All conversions happen at this boundary.
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AlbyNode implements Node against an Alby Hub instance.
|
||||
type AlbyNode struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
|
||||
// token is the full-access JWT.
|
||||
func NewAlbyNode(baseURL, token string) *AlbyNode {
|
||||
return &AlbyNode{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── request helpers ─────────
|
||||
|
||||
func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alby request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
|
||||
return a.do(ctx, "GET", path, nil)
|
||||
}
|
||||
|
||||
func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||
return a.do(ctx, "POST", path, body)
|
||||
}
|
||||
|
||||
// ───────── Node interface ─────────
|
||||
|
||||
type albyInvoice struct {
|
||||
PaymentHash string `json:"paymentHash"`
|
||||
Invoice string `json:"invoice"`
|
||||
Amount int64 `json:"amount"` // sats
|
||||
State string `json:"state"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// CreateInvoice creates a Lightning invoice via Alby Hub.
|
||||
//
|
||||
// Alby Hub speaks satoshis. Amounts that do not divide into whole satoshis are
|
||||
// refused rather than truncated: the ledger works in millisatoshis, and
|
||||
// silently rounding here would mean the ledger and the node disagree about how
|
||||
// much moved, with the difference disappearing.
|
||||
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||
sats, err := exactSats(amountMsat)
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
raw, err := a.post(ctx, "invoices", map[string]any{
|
||||
"amount": sats,
|
||||
"description": memo,
|
||||
})
|
||||
if err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
var inv albyInvoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
|
||||
}
|
||||
expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
|
||||
return Invoice{
|
||||
PaymentHash: inv.PaymentHash,
|
||||
Bolt11: inv.Invoice,
|
||||
AmountMsat: satToMsat(inv.Amount),
|
||||
ExpiresAt: expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LookupInvoice checks whether an invoice has been paid.
|
||||
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||
raw, err := a.get(ctx, "invoices/"+paymentHash)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
var inv albyInvoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
|
||||
}
|
||||
return inv.State == "settled", satToMsat(inv.Amount), nil
|
||||
}
|
||||
|
||||
type albyPayment struct {
|
||||
PaymentHash string `json:"paymentHash"`
|
||||
Preimage string `json:"preimage"`
|
||||
Amount int64 `json:"amountSat"`
|
||||
Fee int64 `json:"feesPaidSat"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// PayInvoice sends an outbound Lightning payment.
|
||||
//
|
||||
// The fee cap is passed to the node as a limit and checked again on the
|
||||
// result. Alby's REST API does not guarantee it will honour a requested cap,
|
||||
// and the caller sizes the cap against what the house is willing to lose on
|
||||
// routing — so an over-priced payment is reported as a failure, which makes
|
||||
// the Service refund the player rather than absorb an unbounded cost.
|
||||
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||
maxFeeSats := maxFeeMsat / 1000
|
||||
|
||||
raw, err := a.post(ctx, "payments", map[string]any{
|
||||
"invoice": bolt11,
|
||||
// Requested limit. Not all versions enforce it, hence the check below.
|
||||
"maxFeeSat": maxFeeSats,
|
||||
})
|
||||
if err != nil {
|
||||
return Payment{}, err
|
||||
}
|
||||
var p albyPayment
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
|
||||
}
|
||||
if p.State != "settled" {
|
||||
return Payment{}, fmt.Errorf("%w: payment %s state=%s",
|
||||
ErrPaymentFailed, p.PaymentHash, p.State)
|
||||
}
|
||||
|
||||
feeMsat := satToMsat(p.Fee)
|
||||
if maxFeeMsat > 0 && feeMsat > maxFeeMsat {
|
||||
// The payment has already gone out — Lightning cannot be recalled. Report
|
||||
// it so the operator sees the overrun rather than discovering it in the
|
||||
// books, and so the Service does not record it as a clean success.
|
||||
return Payment{}, fmt.Errorf(
|
||||
"%w: routing cost %d msat, above the %d msat cap (payment %s already sent)",
|
||||
ErrPaymentFailed, feeMsat, maxFeeMsat, p.PaymentHash)
|
||||
}
|
||||
|
||||
return Payment{
|
||||
PaymentHash: p.PaymentHash,
|
||||
Preimage: p.Preimage,
|
||||
AmountMsat: satToMsat(p.Amount),
|
||||
FeeMsat: satToMsat(p.Fee),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type albyBalances struct {
|
||||
Lightning struct {
|
||||
TotalSpendable int64 `json:"totalSpendableSat"`
|
||||
} `json:"lightning"`
|
||||
}
|
||||
|
||||
// Balance returns the spendable Lightning balance in millisatoshis.
|
||||
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
|
||||
raw, err := a.get(ctx, "balances")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var b albyBalances
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
return 0, fmt.Errorf("parsing alby balances: %w", err)
|
||||
}
|
||||
return satToMsat(b.Lightning.TotalSpendable), nil
|
||||
}
|
||||
|
||||
// ───────── sat ↔ msat conversion ─────────
|
||||
|
||||
func satToMsat(sats int64) int64 { return sats * 1000 }
|
||||
|
||||
// exactSats converts millisatoshis to satoshis, refusing any amount that would
|
||||
// lose precision.
|
||||
//
|
||||
// Lightning cannot carry sub-satoshi amounts. Truncating would mean the ledger
|
||||
// debits 1500 msat while 1000 msat actually leaves, and the missing 500 would
|
||||
// be unaccounted — the kind of drift that only shows up as a books-do-not-
|
||||
// balance alarm weeks later.
|
||||
func exactSats(msat int64) (int64, error) {
|
||||
if msat <= 0 {
|
||||
return 0, fmt.Errorf("%w: amount %d is not positive", ErrAmountOutOfRange, msat)
|
||||
}
|
||||
if msat%1000 != 0 {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %d msat is not a whole number of satoshis; Lightning cannot send sub-satoshi amounts",
|
||||
ErrAmountOutOfRange, msat)
|
||||
}
|
||||
return msat / 1000, nil
|
||||
}
|
||||
243
pkg/lightning/alby_test.go
Normal file
243
pkg/lightning/alby_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package lightning_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||
)
|
||||
|
||||
// These exercise the Alby Hub client against a mock of its REST API.
|
||||
//
|
||||
// A real node cannot be part of the test suite — it would need channels,
|
||||
// liquidity, and real sats — but everything the client is responsible for can
|
||||
// be: request shape, response parsing, unit conversion, error handling, and
|
||||
// the fee cap. Those are where a client loses money, not the routing.
|
||||
|
||||
// albyMock stands in for Alby Hub. Handlers can be overridden per test.
|
||||
type albyMock struct {
|
||||
*httptest.Server
|
||||
lastPath string
|
||||
lastBody map[string]any
|
||||
invoiceResp string
|
||||
paymentResp string
|
||||
balanceResp string
|
||||
status int
|
||||
}
|
||||
|
||||
func newAlbyMock(t *testing.T) *albyMock {
|
||||
t.Helper()
|
||||
m := &albyMock{
|
||||
invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...",
|
||||
"amount":500,"state":"unpaid",
|
||||
"expiresAt":"2030-01-01T00:00:00Z"}`,
|
||||
paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef",
|
||||
"amountSat":500,"feesPaidSat":2,"state":"settled"}`,
|
||||
balanceResp: `{"lightning":{"totalSpendableSat":15000}}`,
|
||||
status: 200,
|
||||
}
|
||||
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.lastPath = r.URL.Path
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&m.lastBody)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"missing token"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(m.status)
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/invoices"):
|
||||
_, _ = w.Write([]byte(m.invoiceResp))
|
||||
case strings.Contains(r.URL.Path, "/payments"):
|
||||
_, _ = w.Write([]byte(m.paymentResp))
|
||||
case strings.Contains(r.URL.Path, "/balances"):
|
||||
_, _ = w.Write([]byte(m.balanceResp))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestAlbySendsBearerToken(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
node := lightning.NewAlbyNode(m.URL, "test-token")
|
||||
|
||||
if _, err := node.Balance(context.Background()); err != nil {
|
||||
t.Fatalf("authenticated request failed: %v", err)
|
||||
}
|
||||
// And without a token the mock rejects, proving the header is what carries it.
|
||||
bare := lightning.NewAlbyNode(m.URL, "")
|
||||
if _, err := bare.Balance(context.Background()); err == nil {
|
||||
t.Fatal("a request with no token succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error
|
||||
// here is a factor-of-1000 money bug.
|
||||
func TestAlbyConvertsSatsToMillisats(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
bal, err := node.Balance(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bal != 15_000_000 {
|
||||
t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal)
|
||||
}
|
||||
|
||||
inv, err := node.CreateInvoice(context.Background(), 500_000, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inv.AmountMsat != 500_000 {
|
||||
t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat)
|
||||
}
|
||||
// The request must have asked for sats, not millisats.
|
||||
if got := m.lastBody["amount"]; got != float64(500) {
|
||||
t.Fatalf("asked Alby for amount %v, want 500 sats", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fee cap must actually be enforced. The Service computes a cap and relies
|
||||
// on the node refusing anything above it; a client that ignores the cap turns
|
||||
// a bounded cost into an unbounded one.
|
||||
func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
// Alby reports a 50-sat fee on this route.
|
||||
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||
"feesPaidSat":50,"state":"settled"}`
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
// Cap of 10 sats. 50 > 10, so this must fail.
|
||||
_, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||
if err == nil {
|
||||
t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap")
|
||||
}
|
||||
if !errors.Is(err, lightning.ErrPaymentFailed) {
|
||||
t.Fatalf("got %v, want ErrPaymentFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||
"feesPaidSat":2,"state":"settled"}`
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||
if err != nil {
|
||||
t.Fatalf("a payment within the cap was refused: %v", err)
|
||||
}
|
||||
if p.FeeMsat != 2_000 {
|
||||
t.Fatalf("fee = %d msat, want 2000", p.FeeMsat)
|
||||
}
|
||||
}
|
||||
|
||||
// A payment that has not settled must not be reported as success. Treating
|
||||
// "pending" as paid would credit a withdrawal that may still fail.
|
||||
func TestAlbyUnsettledPaymentIsAnError(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000,
|
||||
"feesPaidSat":1,"state":"pending"}`
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil {
|
||||
t.Fatal("a pending payment was reported as settled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbyInvoiceSettlementState(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||
"state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||
settled, amt, err := node.LookupInvoice(context.Background(), "h")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settled {
|
||||
t.Fatal("an unpaid invoice reported as settled")
|
||||
}
|
||||
if amt != 300_000 {
|
||||
t.Fatalf("amount = %d msat, want 300000", amt)
|
||||
}
|
||||
|
||||
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||
"state":"settled","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||
settled, _, err = node.LookupInvoice(context.Background(), "h")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settled {
|
||||
t.Fatal("a settled invoice reported as unpaid")
|
||||
}
|
||||
}
|
||||
|
||||
// An error from the node must surface, not be swallowed into a zero value that
|
||||
// downstream code reads as "no balance" or "not settled".
|
||||
func TestAlbyErrorsSurface(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
m.status = 500
|
||||
m.balanceResp = `{"error":"node offline"}`
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
if _, err := node.Balance(context.Background()); err == nil {
|
||||
t.Fatal("a 500 from the node was not reported as an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbyMalformedResponseIsAnError(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
m.balanceResp = `not json at all`
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
if _, err := node.Balance(context.Background()); err == nil {
|
||||
t.Fatal("a malformed response was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A withdrawal amount that does not divide into whole satoshis must not
|
||||
// silently short the player. Lightning cannot send sub-satoshi amounts, so the
|
||||
// client must refuse rather than truncate.
|
||||
func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
// 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited
|
||||
// 1500 msat, quietly costing the player 500 msat.
|
||||
_, err := node.CreateInvoice(context.Background(), 1_500, "dust")
|
||||
if err == nil {
|
||||
t.Fatal("an amount with sub-satoshi precision was accepted; " +
|
||||
"it would be truncated and the difference lost")
|
||||
}
|
||||
}
|
||||
|
||||
// The client must respect a cancelled context rather than hanging.
|
||||
func TestAlbyHonoursContextCancellation(t *testing.T) {
|
||||
m := newAlbyMock(t)
|
||||
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := node.Balance(ctx); err == nil {
|
||||
t.Fatal("a cancelled context did not stop the request")
|
||||
}
|
||||
}
|
||||
|
||||
// The client must satisfy the Node interface the Service depends on.
|
||||
func TestAlbyImplementsNode(t *testing.T) {
|
||||
var _ lightning.Node = (*lightning.AlbyNode)(nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
396
pkg/lightning/lightning.go
Normal file
396
pkg/lightning/lightning.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// PayHeld sends a payment for funds the caller has already debited.
|
||||
//
|
||||
// The LNURL flow debits when the code is issued, because a code is an
|
||||
// authorisation to pull an exact amount and leaving the balance spendable
|
||||
// meanwhile would let a player cash out and bet the same sats before the
|
||||
// wallet claims them. By the time the wallet calls back, the money is already
|
||||
// out of the player's balance and sitting with the bridge, so this must not
|
||||
// debit again.
|
||||
//
|
||||
// On failure the funds are returned to the player, matching what the queued
|
||||
// path does.
|
||||
func (s *Service) PayHeld(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (Payment, error) {
|
||||
maxFee := amountMsat * s.limits.MaxFeeRateBP / 10000
|
||||
|
||||
payment, err := s.node.PayInvoice(ctx, bolt11, maxFee)
|
||||
if err != nil {
|
||||
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||
return Payment{}, fmt.Errorf(
|
||||
"payment failed (%v) and the refund also failed: %w", err, rerr)
|
||||
}
|
||||
return Payment{}, fmt.Errorf("%w: %v", ErrPaymentFailed, err)
|
||||
}
|
||||
|
||||
// Record it alongside the queued withdrawals so the operator sees one
|
||||
// history rather than two.
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO lightning_withdrawals
|
||||
(account_id, bolt11, amount_msat, status, payment_hash, fee_msat, resolved_at)
|
||||
VALUES ($1, $2, $3, 'paid', $4, $5, now())`,
|
||||
accountID, bolt11, amountMsat, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||
// The payment is already gone; failing to record it is a reporting
|
||||
// problem, not a money problem, so surface it and continue.
|
||||
fmt.Printf("lightning: paid %s but could not record it: %v\n",
|
||||
payment.PaymentHash, err)
|
||||
}
|
||||
return payment, 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)
|
||||
}
|
||||
}
|
||||
169
pkg/lnurl/bech32.go
Normal file
169
pkg/lnurl/bech32.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Package lnurl implements LNURL-withdraw, so cashing out is a scan rather
|
||||
// than an errand.
|
||||
//
|
||||
// Without it, withdrawing means: open your wallet, create an invoice for
|
||||
// exactly the right amount, copy it, come back, paste it. That is the least
|
||||
// approachable thing in the arcade and the step most likely to end with
|
||||
// someone giving up and leaving sats behind.
|
||||
//
|
||||
// With LNURL-withdraw the arcade shows a code, the player's wallet scans it,
|
||||
// and the wallet pulls the funds. The player never types an amount or handles
|
||||
// an invoice.
|
||||
package lnurl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
// bech32Polymod is the checksum function from BIP-173.
|
||||
func bech32Polymod(values []byte) uint32 {
|
||||
gen := []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
||||
chk := uint32(1)
|
||||
for _, v := range values {
|
||||
top := chk >> 25
|
||||
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
|
||||
for i := 0; i < 5; i++ {
|
||||
if (top>>uint(i))&1 == 1 {
|
||||
chk ^= gen[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
func hrpExpand(hrp string) []byte {
|
||||
out := make([]byte, 0, len(hrp)*2+1)
|
||||
for _, c := range hrp {
|
||||
out = append(out, byte(c)>>5)
|
||||
}
|
||||
out = append(out, 0)
|
||||
for _, c := range hrp {
|
||||
out = append(out, byte(c)&31)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func createChecksum(hrp string, data []byte) []byte {
|
||||
values := append(hrpExpand(hrp), data...)
|
||||
values = append(values, 0, 0, 0, 0, 0, 0)
|
||||
polymod := bech32Polymod(values) ^ 1
|
||||
out := make([]byte, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
out[i] = byte(polymod>>uint(5*(5-i))) & 31
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verifyChecksum(hrp string, data []byte) bool {
|
||||
return bech32Polymod(append(hrpExpand(hrp), data...)) == 1
|
||||
}
|
||||
|
||||
// convertBits regroups a byte stream between bit widths, which is how bech32
|
||||
// packs 8-bit data into 5-bit symbols.
|
||||
func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
|
||||
var acc uint32
|
||||
var bits uint
|
||||
maxv := uint32(1)<<to - 1
|
||||
var out []byte
|
||||
|
||||
for _, b := range data {
|
||||
if from == 8 && b>>from != 0 {
|
||||
return nil, fmt.Errorf("lnurl: byte %d exceeds %d bits", b, from)
|
||||
}
|
||||
acc = acc<<from | uint32(b)
|
||||
bits += from
|
||||
for bits >= to {
|
||||
bits -= to
|
||||
out = append(out, byte(acc>>bits)&byte(maxv))
|
||||
}
|
||||
}
|
||||
if pad {
|
||||
if bits > 0 {
|
||||
out = append(out, byte(acc<<(to-bits))&byte(maxv))
|
||||
}
|
||||
} else if bits >= from || byte(acc<<(to-bits))&byte(maxv) != 0 {
|
||||
return nil, fmt.Errorf("lnurl: invalid padding")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Encode renders data as a bech32 string under the given human-readable part.
|
||||
func Encode(hrp string, data []byte) (string, error) {
|
||||
converted, err := convertBits(data, 8, 5, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
combined := append(converted, createChecksum(hrp, converted)...)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hrp)
|
||||
sb.WriteByte('1')
|
||||
for _, c := range combined {
|
||||
if int(c) >= len(charset) {
|
||||
return "", fmt.Errorf("lnurl: symbol %d out of range", c)
|
||||
}
|
||||
sb.WriteByte(charset[c])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Decode parses a bech32 string back into its human-readable part and data.
|
||||
func Decode(s string) (string, []byte, error) {
|
||||
// Mixed case is explicitly invalid: it makes the checksum ambiguous.
|
||||
lower, upper := strings.ToLower(s), strings.ToUpper(s)
|
||||
if s != lower && s != upper {
|
||||
return "", nil, fmt.Errorf("lnurl: mixed case")
|
||||
}
|
||||
s = lower
|
||||
|
||||
pos := strings.LastIndex(s, "1")
|
||||
if pos < 1 || pos+7 > len(s) {
|
||||
return "", nil, fmt.Errorf("lnurl: no separator or too short")
|
||||
}
|
||||
hrp := s[:pos]
|
||||
|
||||
data := make([]byte, 0, len(s)-pos-1)
|
||||
for _, c := range s[pos+1:] {
|
||||
idx := strings.IndexRune(charset, c)
|
||||
if idx < 0 {
|
||||
return "", nil, fmt.Errorf("lnurl: character %q not in charset", c)
|
||||
}
|
||||
data = append(data, byte(idx))
|
||||
}
|
||||
if !verifyChecksum(hrp, data) {
|
||||
return "", nil, fmt.Errorf("lnurl: bad checksum")
|
||||
}
|
||||
|
||||
converted, err := convertBits(data[:len(data)-6], 5, 8, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return hrp, converted, nil
|
||||
}
|
||||
|
||||
// EncodeURL renders a URL as an LNURL string.
|
||||
//
|
||||
// Wallets accept it uppercase, which is what makes the QR compact: uppercase
|
||||
// bech32 encodes in QR alphanumeric mode rather than byte mode.
|
||||
func EncodeURL(url string) (string, error) {
|
||||
s, err := Encode("lnurl", []byte(url))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(s), nil
|
||||
}
|
||||
|
||||
// DecodeURL parses an LNURL back into the URL it carries.
|
||||
func DecodeURL(s string) (string, error) {
|
||||
hrp, data, err := Decode(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hrp != "lnurl" {
|
||||
return "", fmt.Errorf("lnurl: unexpected prefix %q", hrp)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
133
pkg/lnurl/bech32_test.go
Normal file
133
pkg/lnurl/bech32_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package lnurl_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||
)
|
||||
|
||||
// The BIP-173 test vectors. An implementation that passes these produces
|
||||
// strings other wallets will accept; one that does not produces codes that
|
||||
// simply fail to scan, with no useful error for the player.
|
||||
func TestBIP173ValidVectors(t *testing.T) {
|
||||
valid := []string{
|
||||
"A12UEL5L",
|
||||
"a12uel5l",
|
||||
"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs",
|
||||
"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw",
|
||||
// The 90-character vector, built rather than transcribed: getting the
|
||||
// run length wrong by hand produces a checksum failure that looks like
|
||||
// an implementation bug.
|
||||
"11" + strings.Repeat("q", 82) + "c8247j",
|
||||
"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w",
|
||||
"?1ezyfcl",
|
||||
}
|
||||
for _, v := range valid {
|
||||
if _, _, err := lnurl.Decode(v); err != nil {
|
||||
t.Errorf("valid vector %q rejected: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBIP173InvalidVectors(t *testing.T) {
|
||||
invalid := map[string]string{
|
||||
"A12UEL5X": "bad checksum",
|
||||
"pzry9x0s0muk": "no separator",
|
||||
"1pzry9x0s0muk": "empty hrp",
|
||||
"x1b4n0q5v": "invalid character",
|
||||
"li1dgmt3": "too short",
|
||||
"A1G7SGD8": "bad checksum",
|
||||
"10a06t8": "empty hrp",
|
||||
"1qzzfhee": "empty hrp",
|
||||
"abc1rzg": "too short",
|
||||
"in1muywd": "bad checksum",
|
||||
"A12Uel5l": "mixed case",
|
||||
}
|
||||
for v, why := range invalid {
|
||||
if _, _, err := lnurl.Decode(v); err == nil {
|
||||
t.Errorf("invalid vector %q (%s) was accepted", v, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
cases := []string{
|
||||
"https://arcade.lan/lnurl/withdraw?k1=abc123",
|
||||
"http://10.0.0.5:8080/lnurl/withdraw?k1=" + strings.Repeat("f", 64),
|
||||
"https://example.com/",
|
||||
}
|
||||
for _, url := range cases {
|
||||
encoded, err := lnurl.EncodeURL(url)
|
||||
if err != nil {
|
||||
t.Fatalf("encoding %q: %v", url, err)
|
||||
}
|
||||
// Wallets receive these uppercase, so decoding must handle that.
|
||||
decoded, err := lnurl.DecodeURL(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding %q: %v", encoded, err)
|
||||
}
|
||||
if decoded != url {
|
||||
t.Fatalf("round trip changed the URL: %q -> %q", url, decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LNURL strings are uppercase so the QR encodes in alphanumeric mode, which is
|
||||
// substantially denser than byte mode and keeps the code scannable on a phone.
|
||||
func TestEncodedLNURLIsUppercase(t *testing.T) {
|
||||
s, err := lnurl.EncodeURL("https://arcade.lan/lnurl/withdraw?k1=deadbeef")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != strings.ToUpper(s) {
|
||||
t.Fatalf("LNURL is not uppercase: %q", s)
|
||||
}
|
||||
if !strings.HasPrefix(s, "LNURL1") {
|
||||
t.Fatalf("LNURL lacks the expected prefix: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A tampered character must fail the checksum rather than decode to a
|
||||
// different URL — otherwise a corrupted scan could point a wallet somewhere
|
||||
// unintended.
|
||||
func TestTamperingIsDetected(t *testing.T) {
|
||||
original := "https://arcade.lan/lnurl/withdraw?k1=abc123"
|
||||
encoded, err := lnurl.EncodeURL(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
detected := 0
|
||||
attempts := 0
|
||||
for i := 6; i < len(encoded); i++ {
|
||||
for _, sub := range "QPZRY9X8" {
|
||||
if rune(encoded[i]) == sub {
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
tampered := encoded[:i] + string(sub) + encoded[i+1:]
|
||||
if _, err := lnurl.DecodeURL(tampered); err != nil {
|
||||
detected++
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempts == 0 {
|
||||
t.Fatal("no tampering attempts were made")
|
||||
}
|
||||
// The checksum catches all single-character substitutions by design.
|
||||
if detected != attempts {
|
||||
t.Fatalf("only %d of %d single-character changes were detected", detected, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPrefixRejected(t *testing.T) {
|
||||
// A valid bech32 string that is not an LNURL must not be accepted as one.
|
||||
other, err := lnurl.Encode("lnbc", []byte("not an lnurl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := lnurl.DecodeURL(other); err == nil {
|
||||
t.Fatal("a non-LNURL bech32 string was accepted as an LNURL")
|
||||
}
|
||||
}
|
||||
229
pkg/lnurl/withdraw.go
Normal file
229
pkg/lnurl/withdraw.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package lnurl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Withdraw implements the LNURL-withdraw exchange.
|
||||
//
|
||||
// The flow, from the player's side, is: tap Cash out, scan the code, done.
|
||||
// Underneath:
|
||||
//
|
||||
// 1. The arcade mints a single-use token (k1) bound to one account and one
|
||||
// amount, and shows it as an LNURL.
|
||||
// 2. The player's wallet fetches the URL and reads the terms.
|
||||
// 3. The wallet generates an invoice for the amount and calls back with it.
|
||||
// 4. The arcade pays that invoice.
|
||||
//
|
||||
// Step 4 is why the token must be strictly single-use. It is a bearer
|
||||
// instrument: anyone holding it can direct a payment to an invoice of their
|
||||
// choosing. So it is random, short-lived, consumed on first use, and bound to
|
||||
// the amount it was issued for.
|
||||
var (
|
||||
ErrUnknownToken = errors.New("lnurl: token not recognised or already used")
|
||||
ErrExpired = errors.New("lnurl: token has expired")
|
||||
ErrAmountRange = errors.New("lnurl: invoice amount outside the permitted range")
|
||||
)
|
||||
|
||||
// TokenTTL is how long a withdraw code stays valid. Short, because it is a
|
||||
// bearer instrument: a code left on screen at a party should stop working
|
||||
// well before anyone wanders off with a photograph of it.
|
||||
const TokenTTL = 5 * time.Minute
|
||||
|
||||
// Token is one outstanding withdraw authorisation.
|
||||
type Token struct {
|
||||
K1 string
|
||||
AccountID int64
|
||||
AmountMsat int64
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// WithdrawRequest is the JSON a wallet reads after scanning.
|
||||
// Field names are fixed by the LNURL specification.
|
||||
type WithdrawRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
Callback string `json:"callback"`
|
||||
K1 string `json:"k1"`
|
||||
DefaultDescription string `json:"defaultDescription"`
|
||||
MinWithdrawable int64 `json:"minWithdrawable"`
|
||||
MaxWithdrawable int64 `json:"maxWithdrawable"`
|
||||
}
|
||||
|
||||
// Response is the LNURL result envelope.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func OK() Response { return Response{Status: "OK"} }
|
||||
func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} }
|
||||
|
||||
// Service issues and redeems withdraw tokens.
|
||||
//
|
||||
// Tokens live in memory rather than the database. They are short-lived and
|
||||
// worthless once used, and keeping them out of durable storage means a lost
|
||||
// instance cannot leave a valid bearer token lying around to be replayed
|
||||
// later.
|
||||
type Service struct {
|
||||
// BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080.
|
||||
BaseURL string
|
||||
|
||||
mu sync.Mutex
|
||||
tokens map[string]Token
|
||||
}
|
||||
|
||||
func NewService(baseURL string) *Service {
|
||||
return &Service{BaseURL: baseURL, tokens: make(map[string]Token)}
|
||||
}
|
||||
|
||||
// Issue mints a withdraw code for an exact amount.
|
||||
func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) {
|
||||
if amountMsat <= 0 {
|
||||
return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange)
|
||||
}
|
||||
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
panic("lnurl: system randomness unavailable: " + err.Error())
|
||||
}
|
||||
k1 = hex.EncodeToString(raw[:])
|
||||
|
||||
s.mu.Lock()
|
||||
s.sweepLocked()
|
||||
s.tokens[k1] = Token{
|
||||
K1: k1, AccountID: accountID, AmountMsat: amountMsat,
|
||||
Expires: time.Now().Add(TokenTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1)
|
||||
encoded, err := EncodeURL(url)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return encoded, k1, nil
|
||||
}
|
||||
|
||||
// Describe returns the terms a wallet reads after scanning.
|
||||
//
|
||||
// The minimum and maximum are set to the same value, which is what tells the
|
||||
// wallet to withdraw exactly this amount rather than prompting the player to
|
||||
// choose one. Choosing an amount is the step this whole mechanism exists to
|
||||
// remove.
|
||||
func (s *Service) Describe(k1 string) (*WithdrawRequest, error) {
|
||||
t, err := s.lookup(k1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WithdrawRequest{
|
||||
Tag: "withdrawRequest",
|
||||
Callback: s.BaseURL + "/lnurl/withdraw/callback",
|
||||
K1: t.K1,
|
||||
DefaultDescription: "Quantum Arcade cash out",
|
||||
MinWithdrawable: t.AmountMsat,
|
||||
MaxWithdrawable: t.AmountMsat,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Redeem consumes a token and returns what it authorises.
|
||||
//
|
||||
// The token is deleted before the payment is attempted. A token that is
|
||||
// consumed and then fails to pay costs the player a retry; a token that is
|
||||
// left valid after a payment succeeds costs the house the whole balance
|
||||
// again. The asymmetry decides the ordering.
|
||||
func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) {
|
||||
s.mu.Lock()
|
||||
t, ok := s.tokens[k1]
|
||||
if ok {
|
||||
delete(s.tokens, k1)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return Token{}, ErrUnknownToken
|
||||
}
|
||||
if time.Now().After(t.Expires) {
|
||||
return Token{}, ErrExpired
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Restore puts a token back after a failed payment, so a routing failure does
|
||||
// not silently swallow the player's cash-out.
|
||||
func (s *Service) Restore(t Token) {
|
||||
if time.Now().After(t.Expires) {
|
||||
return // no point restoring something already expired
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.tokens[t.K1] = t
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ForceStore inserts a token regardless of its expiry. It exists so tests can
|
||||
// construct an aged token; production code uses Restore, which refuses to
|
||||
// resurrect something already expired.
|
||||
func (s *Service) ForceStore(t Token) {
|
||||
s.mu.Lock()
|
||||
s.tokens[t.K1] = t
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) lookup(k1 string) (Token, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.tokens[k1]
|
||||
if !ok {
|
||||
return Token{}, ErrUnknownToken
|
||||
}
|
||||
if time.Now().After(t.Expires) {
|
||||
delete(s.tokens, k1)
|
||||
return Token{}, ErrExpired
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// sweepLocked drops expired tokens. Called under the mutex.
|
||||
//
|
||||
// It discards them rather than reporting them, because Issue does not know how
|
||||
// to refund. Callers that must refund use Expired instead.
|
||||
func (s *Service) sweepLocked() {
|
||||
now := time.Now()
|
||||
for k, t := range s.tokens {
|
||||
if now.After(t.Expires) {
|
||||
delete(s.tokens, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expired removes and returns every token past its lifetime.
|
||||
//
|
||||
// The funds behind a code are debited when it is issued, so a code that is
|
||||
// never scanned leaves a player short. The caller refunds what this returns —
|
||||
// which is why the tokens are handed back rather than quietly dropped.
|
||||
func (s *Service) Expired() []Token {
|
||||
now := time.Now()
|
||||
var out []Token
|
||||
|
||||
s.mu.Lock()
|
||||
for k, t := range s.tokens {
|
||||
if now.After(t.Expires) {
|
||||
out = append(out, t)
|
||||
delete(s.tokens, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Outstanding reports how many tokens are live, for tests and the admin view.
|
||||
func (s *Service) Outstanding() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.tokens)
|
||||
}
|
||||
287
pkg/lnurl/withdraw_test.go
Normal file
287
pkg/lnurl/withdraw_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package lnurl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||
)
|
||||
|
||||
// A withdraw token is a bearer instrument: whoever holds it can direct a
|
||||
// payment. These pin down the properties that keeps it from being abused.
|
||||
|
||||
func TestIssuedCodeIsScannable(t *testing.T) {
|
||||
s := lnurl.NewService("http://10.0.0.5:8080")
|
||||
|
||||
code, k1, err := s.Issue(42, 50_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(code, "LNURL1") {
|
||||
t.Fatalf("code does not look like an LNURL: %q", code)
|
||||
}
|
||||
|
||||
// A wallet decodes it and must reach a URL carrying this token.
|
||||
url, err := lnurl.DecodeURL(code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(url, k1) {
|
||||
t.Fatalf("decoded URL %q does not carry the token", url)
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://10.0.0.5:8080/") {
|
||||
t.Fatalf("decoded URL points elsewhere: %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// The terms must pin the amount exactly. A range would make the wallet prompt
|
||||
// the player to choose, which is the step this exists to remove.
|
||||
func TestTermsPinTheExactAmount(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(7, 12_345_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := s.Describe(k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Tag != "withdrawRequest" {
|
||||
t.Fatalf("tag = %q, want withdrawRequest", req.Tag)
|
||||
}
|
||||
if req.MinWithdrawable != req.MaxWithdrawable {
|
||||
t.Fatalf("min %d and max %d differ; the wallet would prompt for an amount",
|
||||
req.MinWithdrawable, req.MaxWithdrawable)
|
||||
}
|
||||
if req.MinWithdrawable != 12_345_000 {
|
||||
t.Fatalf("amount = %d, want 12345000", req.MinWithdrawable)
|
||||
}
|
||||
if req.K1 != k1 {
|
||||
t.Fatal("terms carry a different token than was issued")
|
||||
}
|
||||
}
|
||||
|
||||
// The defining property: a token pays once.
|
||||
func TestTokenIsSingleUse(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.Redeem(context.Background(), k1); err != nil {
|
||||
t.Fatalf("first redemption failed: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("redemption %d gave %v, want ErrUnknownToken", i+2, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two wallets racing on one code — or one wallet retrying — must yield a
|
||||
// single payment.
|
||||
func TestConcurrentRedemptionYieldsOne(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var wins atomic.Int64
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := s.Redeem(context.Background(), k1); err == nil {
|
||||
wins.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("%d concurrent redemptions succeeded, want 1", wins.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTokenRejected(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
if _, err := s.Redeem(context.Background(), "not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("got %v, want ErrUnknownToken", err)
|
||||
}
|
||||
if _, err := s.Describe("not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("Describe gave %v, want ErrUnknownToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A code photographed at a party must stop working.
|
||||
func TestExpiredTokenRejected(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(1, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Age the token past its lifetime by redeeming and restoring an expired copy.
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok.Expires = time.Now().Add(-time.Second)
|
||||
s.Restore(tok)
|
||||
|
||||
// Restore refuses to resurrect something already expired.
|
||||
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||
t.Fatalf("an expired token was restored and redeemed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed payment must give the player their code back rather than
|
||||
// swallowing the cash-out.
|
||||
func TestRestoreAfterFailedPayment(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1, err := s.Issue(9, 25_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The payment fails here, so the authorisation is put back.
|
||||
s.Restore(tok)
|
||||
|
||||
again, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatalf("a restored token could not be redeemed: %v", err)
|
||||
}
|
||||
if again.AccountID != 9 || again.AmountMsat != 25_000 {
|
||||
t.Fatalf("restored token carries different terms: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens carry the account they were issued for, so a code cannot be used to
|
||||
// drain someone else's balance.
|
||||
func TestTokenIsBoundToItsAccount(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
_, k1a, _ := s.Issue(100, 5_000)
|
||||
_, k1b, _ := s.Issue(200, 7_000)
|
||||
|
||||
a, err := s.Redeem(context.Background(), k1a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Redeem(context.Background(), k1b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.AccountID != 100 || a.AmountMsat != 5_000 {
|
||||
t.Fatalf("first token carries %+v", a)
|
||||
}
|
||||
if b.AccountID != 200 || b.AmountMsat != 7_000 {
|
||||
t.Fatalf("second token carries %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAreUnpredictable(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, k1, err := s.Issue(1, 1_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[k1] {
|
||||
t.Fatalf("token collision after %d issues", i)
|
||||
}
|
||||
if len(k1) != 64 {
|
||||
t.Fatalf("token is %d characters, want 64 hex", len(k1))
|
||||
}
|
||||
seen[k1] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonPositiveAmountRefused(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
for _, amt := range []int64{0, -1, -50_000} {
|
||||
if _, _, err := s.Issue(1, amt); !errors.Is(err, lnurl.ErrAmountRange) {
|
||||
t.Errorf("amount %d gave %v, want ErrAmountRange", amt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expired tokens must not accumulate: a long party would otherwise leak memory
|
||||
// one abandoned cash-out at a time.
|
||||
func TestExpiredTokensAreSweptOnIssue(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
|
||||
// Issue several, then age them by hand.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.Outstanding() != 5 {
|
||||
t.Fatalf("%d tokens outstanding, want 5", s.Outstanding())
|
||||
}
|
||||
|
||||
// A fresh issue after the TTL has passed should clear the stale ones. The
|
||||
// sweep runs on issue, so simulate elapsed time by expiring them directly.
|
||||
// Redeem-and-restore-expired is the only public path, so use Describe to
|
||||
// confirm they are gone after the TTL instead.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nothing has expired yet, so all six remain.
|
||||
if s.Outstanding() != 6 {
|
||||
t.Fatalf("%d tokens outstanding, want 6", s.Outstanding())
|
||||
}
|
||||
}
|
||||
|
||||
// A code that is issued but never scanned must be reported back so the caller
|
||||
// can refund it. The balance is debited at issue time, so silently dropping an
|
||||
// expired token would leave the player short.
|
||||
func TestExpiredTokensAreReturnedForRefund(t *testing.T) {
|
||||
s := lnurl.NewService("http://arcade.lan")
|
||||
|
||||
_, k1, err := s.Issue(55, 31_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nothing has expired yet.
|
||||
if got := s.Expired(); len(got) != 0 {
|
||||
t.Fatalf("%d tokens reported expired immediately", len(got))
|
||||
}
|
||||
|
||||
// Age it by redeeming, expiring the copy, and forcing it back.
|
||||
tok, err := s.Redeem(context.Background(), k1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok.Expires = time.Now().Add(-time.Minute)
|
||||
s.ForceStore(tok)
|
||||
|
||||
expired := s.Expired()
|
||||
if len(expired) != 1 {
|
||||
t.Fatalf("%d tokens returned for refund, want 1", len(expired))
|
||||
}
|
||||
if expired[0].AccountID != 55 || expired[0].AmountMsat != 31_000 {
|
||||
t.Fatalf("expired token carries %+v, want account 55 and 31000 msat", expired[0])
|
||||
}
|
||||
|
||||
// And it must be gone, so a second sweep cannot refund it twice.
|
||||
if got := s.Expired(); len(got) != 0 {
|
||||
t.Fatalf("a second sweep returned %d tokens; a refund could be issued twice", len(got))
|
||||
}
|
||||
}
|
||||
@@ -169,3 +169,40 @@ func Verify(pub *PublicKey, msg, sig []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrivateFromBytes reconstructs a private key from stored material.
|
||||
//
|
||||
// The Ed25519 half is stored as its 32-byte seed rather than the expanded
|
||||
// key, because the seed is the canonical form and cannot encode an
|
||||
// inconsistent pair. The ML-DSA half is stored in its own binary encoding.
|
||||
func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) {
|
||||
if len(edSeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d",
|
||||
ErrMalformedKey, len(edSeed), ed25519.SeedSize)
|
||||
}
|
||||
var pq mldsa65.PrivateKey
|
||||
if err := pq.UnmarshalBinary(pqKey); err != nil {
|
||||
return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err)
|
||||
}
|
||||
return &PrivateKey{
|
||||
Ed: ed25519.NewKeyFromSeed(edSeed),
|
||||
PQ: &pq,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicFromPrivate derives the public half.
|
||||
//
|
||||
// Deriving rather than storing means a client cannot present a public key that
|
||||
// does not match the key it signs with — a mismatch that would otherwise only
|
||||
// surface as a confusing authentication failure.
|
||||
func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) {
|
||||
edPub, ok := priv.Ed.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey)
|
||||
}
|
||||
pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey)
|
||||
}
|
||||
return &PublicKey{Ed: edPub, PQ: pqPub}, nil
|
||||
}
|
||||
|
||||
@@ -208,3 +208,59 @@ func BenchmarkGenerateKey(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key stored by the browser and restored on the next visit must produce
|
||||
// signatures the server still accepts.
|
||||
func TestPrivateKeyRoundTripThroughStorage(t *testing.T) {
|
||||
pub, priv := newKey(t)
|
||||
|
||||
// What the browser would persist.
|
||||
edSeed := priv.Ed.Seed()
|
||||
pqBytes, err := priv.PQ.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restored, err := pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msg := []byte("a challenge issued after the page reloaded")
|
||||
sig, err := pqid.Sign(restored, msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||
t.Fatalf("a restored key produced a signature the original public key rejects: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The public key must be derivable, so a client cannot present one that does
|
||||
// not match what it signs with.
|
||||
func TestPublicKeyDerivesFromPrivate(t *testing.T) {
|
||||
pub, priv := newKey(t)
|
||||
|
||||
derived, err := pqid.PublicFromPrivate(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if derived.Hex() != pub.Hex() {
|
||||
t.Fatal("derived public key does not match the generated one")
|
||||
}
|
||||
if string(derived.ID()) != string(pub.ID()) {
|
||||
t.Fatal("derived public key has a different account id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedStoredKeysRejected(t *testing.T) {
|
||||
_, priv := newKey(t)
|
||||
pqBytes, _ := priv.PQ.MarshalBinary()
|
||||
|
||||
if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||
t.Errorf("short ed seed gave %v, want ErrMalformedKey", err)
|
||||
}
|
||||
if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||
t.Errorf("bad pq key gave %v, want ErrMalformedKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
rid := roundID
|
||||
|
||||
// Pay the full winnings first, then take the fee as its own transaction.
|
||||
// Netting them into one posting would be arithmetically identical but
|
||||
// would hide the deduction: the player would see a smaller win rather
|
||||
// than a win and a charge.
|
||||
if housePays > 0 {
|
||||
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||
rid := roundID
|
||||
if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil {
|
||||
payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||
if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil {
|
||||
return fmt.Errorf("settling round %d: %w", roundID, err)
|
||||
}
|
||||
}
|
||||
if houseKeeps > 0 {
|
||||
feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps})
|
||||
if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil {
|
||||
return fmt.Errorf("collecting fees for round %d: %w", roundID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
|
||||
seedBytes := seed.Bytes()
|
||||
|
||||
@@ -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"
|
||||
@@ -326,6 +327,10 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) {
|
||||
|
||||
func TestCashedOutPlayerIsPaid(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Isolate the payout arithmetic from the fee schedule, which is covered
|
||||
// by its own tests. Mixing them would make this test fail whenever the
|
||||
// operator changed the rake, for no reason connected to what it checks.
|
||||
f.room.Fees = fees.NoFees()
|
||||
id, pk := f.player("a", 100_000)
|
||||
house, err := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||
if err != nil {
|
||||
@@ -763,6 +768,9 @@ func TestAutoCashOutTargetMustExceedOne(t *testing.T) {
|
||||
// An auto cash-out must pay the target exactly, not the tick's multiplier.
|
||||
func TestAutoCashOutPaysTheTargetExactly(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// The claim under test is that the target pays exactly, not what the
|
||||
// operator deducts afterwards.
|
||||
f.room.Fees = fees.NoFees()
|
||||
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||
hp, _ := f.player("housefund", 5_000_000)
|
||||
if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil {
|
||||
@@ -996,3 +1004,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")
|
||||
}
|
||||
}
|
||||
|
||||
440
pkg/tournament/tournament.go
Normal file
440
pkg/tournament/tournament.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Package tournament runs scheduled competitive events.
|
||||
//
|
||||
// A tournament collects entry fees into a prize pool and pays them out to the
|
||||
// best performers over a window of ordinary rounds. Players keep playing the
|
||||
// same games; the tournament simply scores what they do.
|
||||
//
|
||||
// The prize pool is a real ledger account rather than a number in a row. Entry
|
||||
// fees move into it and prizes move out of it, so tournament money obeys the
|
||||
// same double-entry invariants as everything else: it cannot be created,
|
||||
// cannot be lost, and every movement is explained by a posting.
|
||||
//
|
||||
// Two properties the tests pin down, because they are where this kind of code
|
||||
// usually goes wrong:
|
||||
//
|
||||
// - Every millisatoshi collected is paid out. Integer division of a pool
|
||||
// across percentage shares leaves a remainder, and a remainder that is
|
||||
// silently dropped is money that vanishes.
|
||||
// - A tournament settles exactly once, even if two instances try at the
|
||||
// same moment.
|
||||
package tournament
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotRegistering = errors.New("tournament: registration is not open")
|
||||
ErrAlreadyEntered = errors.New("tournament: already entered")
|
||||
ErrFull = errors.New("tournament: entrant limit reached")
|
||||
ErrNotFinished = errors.New("tournament: has not finished yet")
|
||||
ErrAlreadySettled = errors.New("tournament: already settled")
|
||||
ErrBadPayoutSplit = errors.New("tournament: payout shares must sum to 10000 basis points")
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusScheduled Status = "scheduled"
|
||||
StatusRegistering Status = "registering"
|
||||
StatusRunning Status = "running"
|
||||
StatusSettled Status = "settled"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Tournament is a scheduled event.
|
||||
type Tournament struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Game string `json:"game"`
|
||||
Status Status `json:"status"`
|
||||
EntryFeeMsat int64 `json:"entry_fee_msat"`
|
||||
PoolAccountID int64 `json:"-"`
|
||||
PayoutBP []int32 `json:"payout_bp"`
|
||||
MaxEntrants *int32 `json:"max_entrants"`
|
||||
RegistersAt time.Time `json:"registers_at"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt time.Time `json:"ends_at"`
|
||||
PoolMsat int64 `json:"pool_msat"`
|
||||
Entrants int `json:"entrants"`
|
||||
}
|
||||
|
||||
// Standing is one player's place on the board.
|
||||
type Standing struct {
|
||||
Position int `json:"position"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
ScoreMsat int64 `json:"score_msat"`
|
||||
RoundsPlayed int `json:"rounds_played"`
|
||||
PrizeMsat int64 `json:"prize_msat"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, l *ledger.Ledger) *Service {
|
||||
return &Service{pool: pool, ledger: l}
|
||||
}
|
||||
|
||||
// Create schedules a tournament and opens its prize pool account.
|
||||
func (s *Service) Create(ctx context.Context, name, game string,
|
||||
entryFeeMsat int64, payoutBP []int32, maxEntrants *int32,
|
||||
registersAt, startsAt, endsAt time.Time) (*Tournament, error) {
|
||||
|
||||
var total int32
|
||||
for _, bp := range payoutBP {
|
||||
if bp <= 0 {
|
||||
return nil, fmt.Errorf("%w: share %d is not positive", ErrBadPayoutSplit, bp)
|
||||
}
|
||||
total += bp
|
||||
}
|
||||
if total != 10000 {
|
||||
return nil, fmt.Errorf("%w: shares sum to %d", ErrBadPayoutSplit, total)
|
||||
}
|
||||
|
||||
// The pool is a named ledger account, so it appears in the books and in
|
||||
// any audit alongside every other account.
|
||||
poolName := fmt.Sprintf("tournament_pool_%d_%s", time.Now().UnixNano(), game)
|
||||
var poolID int64
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO accounts (kind, name) VALUES ('house', $1) RETURNING id`,
|
||||
poolName).Scan(&poolID); err != nil {
|
||||
return nil, fmt.Errorf("creating prize pool account: %w", err)
|
||||
}
|
||||
|
||||
t := &Tournament{
|
||||
Name: name, Game: game, Status: StatusScheduled,
|
||||
EntryFeeMsat: entryFeeMsat, PoolAccountID: poolID, PayoutBP: payoutBP,
|
||||
MaxEntrants: maxEntrants,
|
||||
RegistersAt: registersAt, StartsAt: startsAt, EndsAt: endsAt,
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO tournaments
|
||||
(name, game, entry_fee_msat, pool_account_id, payout_bp,
|
||||
max_entrants, registers_at, starts_at, ends_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
name, game, entryFeeMsat, poolID, payoutBP, maxEntrants,
|
||||
registersAt, startsAt, endsAt).Scan(&t.ID); err != nil {
|
||||
return nil, fmt.Errorf("creating tournament: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Enter registers a player, moving their entry fee into the prize pool.
|
||||
func (s *Service) Enter(ctx context.Context, tournamentID, accountID int64) error {
|
||||
t, err := s.Get(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Status != StatusRegistering {
|
||||
return fmt.Errorf("%w: status is %s", ErrNotRegistering, t.Status)
|
||||
}
|
||||
if t.MaxEntrants != nil && t.Entrants >= int(*t.MaxEntrants) {
|
||||
return ErrFull
|
||||
}
|
||||
|
||||
// Claim the seat before taking the money. The unique constraint makes a
|
||||
// double entry impossible, and failing here means nothing was charged.
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO tournament_entries (tournament_id, account_id) VALUES ($1, $2)`,
|
||||
tournamentID, accountID); err != nil {
|
||||
return ErrAlreadyEntered
|
||||
}
|
||||
|
||||
if t.EntryFeeMsat > 0 {
|
||||
if _, err := s.ledger.Post(ctx, "tournament_entry", nil, []ledger.Posting{
|
||||
{AccountID: accountID, AmountMsat: -t.EntryFeeMsat},
|
||||
{AccountID: t.PoolAccountID, AmountMsat: t.EntryFeeMsat},
|
||||
}); err != nil {
|
||||
// Could not pay: release the seat so the player can retry once
|
||||
// funded, rather than holding a place they never paid for.
|
||||
if _, derr := s.pool.Exec(ctx,
|
||||
`DELETE FROM tournament_entries
|
||||
WHERE tournament_id = $1 AND account_id = $2`,
|
||||
tournamentID, accountID); derr != nil {
|
||||
fmt.Printf("tournament: could not release unpaid seat: %v\n", derr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordResult adds a round's net result to a player's tournament score.
|
||||
//
|
||||
// Called by settlement for every entrant playing the tournament's game inside
|
||||
// its window. A losing round lowers the score; the board is net profit, so
|
||||
// grinding many small wins and taking one large loss is not a way to climb.
|
||||
func (s *Service) RecordResult(ctx context.Context, tournamentID, accountID, netMsat int64) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournament_entries
|
||||
SET score_msat = score_msat + $3,
|
||||
rounds_played = rounds_played + 1
|
||||
WHERE tournament_id = $1 AND account_id = $2`,
|
||||
tournamentID, accountID, netMsat)
|
||||
return err
|
||||
}
|
||||
|
||||
// Leaderboard returns the current standings, best first.
|
||||
func (s *Service) Leaderboard(ctx context.Context, tournamentID int64, limit int) ([]Standing, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT e.account_id, COALESCE(a.nickname, ''), e.score_msat,
|
||||
e.rounds_played, e.prize_msat
|
||||
FROM tournament_entries e
|
||||
JOIN accounts a ON a.id = e.account_id
|
||||
WHERE e.tournament_id = $1
|
||||
ORDER BY e.score_msat DESC, e.rounds_played ASC, e.id ASC
|
||||
LIMIT $2`, tournamentID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Standing
|
||||
pos := 0
|
||||
for rows.Next() {
|
||||
pos++
|
||||
st := Standing{Position: pos}
|
||||
if err := rows.Scan(&st.AccountID, &st.Nickname, &st.ScoreMsat,
|
||||
&st.RoundsPlayed, &st.PrizeMsat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Settle pays the prize pool out to the leaders and closes the tournament.
|
||||
//
|
||||
// The entire pool is distributed. Integer division of a pool across percentage
|
||||
// shares leaves a remainder, and dropping it would quietly destroy money and
|
||||
// break the ledger's conservation check, so the remainder goes to first place.
|
||||
func (s *Service) Settle(ctx context.Context, tournamentID int64) ([]Standing, error) {
|
||||
// Claim the tournament first: an UPDATE that only matches an unsettled row
|
||||
// means two instances cannot both pay out.
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournaments SET status = 'settled', settled_at = now()
|
||||
WHERE id = $1 AND status IN ('running', 'registering')
|
||||
AND ends_at <= now()`, tournamentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
var status Status
|
||||
var endsAt time.Time
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT status, ends_at FROM tournaments WHERE id = $1`,
|
||||
tournamentID).Scan(&status, &endsAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == StatusSettled {
|
||||
return nil, ErrAlreadySettled
|
||||
}
|
||||
return nil, fmt.Errorf("%w: ends at %s", ErrNotFinished, endsAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
t, err := s.Get(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board, err := s.Leaderboard(ctx, tournamentID, len(t.PayoutBP))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if poolMsat == 0 || len(board) == 0 {
|
||||
return board, nil
|
||||
}
|
||||
|
||||
// Compute each share, then hand the rounding remainder to first place so
|
||||
// the pool empties exactly.
|
||||
postings := make([]ledger.Posting, 0, len(board)+1)
|
||||
var distributed int64
|
||||
prizes := make([]int64, len(board))
|
||||
for i := range board {
|
||||
if i >= len(t.PayoutBP) {
|
||||
break
|
||||
}
|
||||
prize := poolMsat * int64(t.PayoutBP[i]) / 10000
|
||||
prizes[i] = prize
|
||||
distributed += prize
|
||||
}
|
||||
if remainder := poolMsat - distributed; remainder > 0 {
|
||||
prizes[0] += remainder
|
||||
distributed = poolMsat
|
||||
}
|
||||
|
||||
for i, st := range board {
|
||||
if prizes[i] <= 0 {
|
||||
continue
|
||||
}
|
||||
board[i].PrizeMsat = prizes[i]
|
||||
postings = append(postings, ledger.Posting{
|
||||
AccountID: st.AccountID, AmountMsat: prizes[i]})
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournament_entries SET prize_msat = $3
|
||||
WHERE tournament_id = $1 AND account_id = $2`,
|
||||
tournamentID, st.AccountID, prizes[i]); err != nil {
|
||||
return nil, fmt.Errorf("recording prize: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if distributed > 0 {
|
||||
postings = append(postings, ledger.Posting{
|
||||
AccountID: t.PoolAccountID, AmountMsat: -distributed})
|
||||
if _, err := s.ledger.Post(ctx, "tournament_prize", nil, postings); err != nil {
|
||||
return nil, fmt.Errorf("paying prizes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The pool must be empty. Anything left would be money stranded in an
|
||||
// account nobody can reach.
|
||||
left, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if left != 0 {
|
||||
return nil, fmt.Errorf("tournament %d settled with %d msat stranded in its pool",
|
||||
tournamentID, left)
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
// Cancel refunds every entry fee and closes the tournament.
|
||||
func (s *Service) Cancel(ctx context.Context, tournamentID int64) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournaments SET status = 'cancelled', settled_at = now()
|
||||
WHERE id = $1 AND status NOT IN ('settled', 'cancelled')`, tournamentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAlreadySettled
|
||||
}
|
||||
|
||||
t, err := s.Get(ctx, tournamentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if poolMsat == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT account_id FROM tournament_entries WHERE tournament_id = $1`,
|
||||
tournamentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entrants []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
entrants = append(entrants, id)
|
||||
}
|
||||
rows.Close()
|
||||
if len(entrants) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refund the fee each player paid. The pool holds exactly the sum of
|
||||
// those fees, so refunding the entry fee to each empties it precisely.
|
||||
postings := make([]ledger.Posting, 0, len(entrants)+1)
|
||||
var total int64
|
||||
for _, id := range entrants {
|
||||
postings = append(postings, ledger.Posting{AccountID: id, AmountMsat: t.EntryFeeMsat})
|
||||
total += t.EntryFeeMsat
|
||||
}
|
||||
if total != poolMsat {
|
||||
return fmt.Errorf("tournament %d: pool holds %d but refunds total %d",
|
||||
tournamentID, poolMsat, total)
|
||||
}
|
||||
postings = append(postings, ledger.Posting{AccountID: t.PoolAccountID, AmountMsat: -total})
|
||||
|
||||
_, err = s.ledger.Post(ctx, "tournament_refund", nil, postings)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get loads a tournament with its live pool balance and entrant count.
|
||||
func (s *Service) Get(ctx context.Context, id int64) (*Tournament, error) {
|
||||
var t Tournament
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name, game, status, entry_fee_msat, pool_account_id,
|
||||
payout_bp, max_entrants, registers_at, starts_at, ends_at
|
||||
FROM tournaments WHERE id = $1`, id).
|
||||
Scan(&t.ID, &t.Name, &t.Game, &t.Status, &t.EntryFeeMsat, &t.PoolAccountID,
|
||||
&t.PayoutBP, &t.MaxEntrants, &t.RegistersAt, &t.StartsAt, &t.EndsAt); err != nil {
|
||||
return nil, fmt.Errorf("tournament %d not found: %w", id, err)
|
||||
}
|
||||
t.PoolMsat, _ = s.ledger.Balance(ctx, t.PoolAccountID)
|
||||
_ = s.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM tournament_entries WHERE tournament_id = $1`,
|
||||
id).Scan(&t.Entrants)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// AdvanceSchedules moves tournaments through their lifecycle by wall clock.
|
||||
// Any instance may run it; the updates are idempotent.
|
||||
func (s *Service) AdvanceSchedules(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournaments SET status = 'registering'
|
||||
WHERE status = 'scheduled' AND registers_at <= now()`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE tournaments SET status = 'running'
|
||||
WHERE status = 'registering' AND starts_at <= now()`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active lists tournaments a player can currently see or join.
|
||||
func (s *Service) Active(ctx context.Context) ([]Tournament, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id FROM tournaments
|
||||
WHERE status IN ('scheduled', 'registering', 'running')
|
||||
ORDER BY starts_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
out := make([]Tournament, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
t, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, *t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
529
pkg/tournament/tournament_test.go
Normal file
529
pkg/tournament/tournament_test.go
Normal file
@@ -0,0 +1,529 @@
|
||||
package tournament_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||
"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 *tournament.Service
|
||||
ledger *ledger.Ledger
|
||||
pool *pgxpool.Pool
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
l := ledger.New(pool)
|
||||
return &fixture{t: t, svc: tournament.New(pool, l), ledger: l,
|
||||
pool: pool, ctx: context.Background()}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// open creates a tournament already accepting entries and ending in the past,
|
||||
// so tests can settle without waiting.
|
||||
func (f *fixture) open(entryFee int64, split []int32, ended bool) *tournament.Tournament {
|
||||
f.t.Helper()
|
||||
now := time.Now()
|
||||
ends := now.Add(time.Hour)
|
||||
if ended {
|
||||
ends = now.Add(-time.Minute)
|
||||
}
|
||||
t, err := f.svc.Create(f.ctx, "Test Cup", "rocket", entryFee, split, nil,
|
||||
now.Add(-time.Hour), now.Add(-30*time.Minute), ends)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
// Registration must be open for entries; AdvanceSchedules may have moved
|
||||
// it straight to running.
|
||||
if _, err := f.pool.Exec(f.ctx,
|
||||
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, t.ID); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/* ---------------- creation ---------------- */
|
||||
|
||||
func TestPayoutSplitMustSumToWhole(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
now := time.Now()
|
||||
for _, split := range [][]int32{
|
||||
{5000, 3000}, // 80%
|
||||
{6000, 5000}, // 110%
|
||||
{10000, 1}, // over
|
||||
{}, // nothing
|
||||
} {
|
||||
_, err := f.svc.Create(f.ctx, "bad", "rocket", 1000, split, nil,
|
||||
now, now.Add(time.Minute), now.Add(time.Hour))
|
||||
if !errors.Is(err, tournament.ErrBadPayoutSplit) {
|
||||
t.Errorf("split %v gave %v, want ErrBadPayoutSplit", split, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOpensAPrizePool(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(0, []int32{10000}, false)
|
||||
if tn.PoolMsat != 0 {
|
||||
t.Fatalf("new pool holds %d, want 0", tn.PoolMsat)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- entry ---------------- */
|
||||
|
||||
func TestEntryFeeMovesIntoThePool(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(10_000, []int32{10000}, false)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
if before-after != 10_000 {
|
||||
t.Fatalf("entry cost %d, want 10000", before-after)
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.PoolMsat != 10_000 {
|
||||
t.Fatalf("pool holds %d, want 10000", got.PoolMsat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotEnterTwice(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(5_000, []int32{10000}, false)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrAlreadyEntered) {
|
||||
t.Fatalf("got %v, want ErrAlreadyEntered", err)
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.PoolMsat != 5_000 {
|
||||
t.Fatalf("pool holds %d after a duplicate attempt, want 5000", got.PoolMsat)
|
||||
}
|
||||
}
|
||||
|
||||
// A player who cannot afford the fee must not hold a seat.
|
||||
func TestUnfundedEntryTakesNoSeat(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(50_000, []int32{10000}, false)
|
||||
id := f.player("broke", 100)
|
||||
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err == nil {
|
||||
t.Fatal("an unfunded player entered")
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.Entrants != 0 {
|
||||
t.Fatalf("%d entrants after a failed payment, want 0", got.Entrants)
|
||||
}
|
||||
|
||||
// And they can enter properly once funded.
|
||||
if _, err := f.ledger.Deposit(f.ctx, id, 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatalf("could not enter after funding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentEntriesChargeOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(10_000, []int32{10000}, false)
|
||||
id := f.player("a", 1_000_000)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = f.svc.Enter(f.ctx, tn.ID, id)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
ok := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
if ok != 1 {
|
||||
t.Fatalf("%d concurrent entries succeeded, want 1", ok)
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.PoolMsat != 10_000 {
|
||||
t.Fatalf("pool holds %d, want a single fee of 10000", got.PoolMsat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrantLimitIsEnforced(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
now := time.Now()
|
||||
max := int32(2)
|
||||
tn, err := f.svc.Create(f.ctx, "small", "rocket", 1_000, []int32{10000}, &max,
|
||||
now.Add(-time.Hour), now.Add(time.Hour), now.Add(2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.pool.Exec(f.ctx,
|
||||
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, tn.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, f.player(fmt.Sprintf("p%d", i), 100_000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, f.player("late", 100_000)); !errors.Is(err, tournament.ErrFull) {
|
||||
t.Fatalf("got %v, want ErrFull", err)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- scoring ---------------- */
|
||||
|
||||
func TestLeaderboardOrdersByScore(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(0, []int32{10000}, false)
|
||||
|
||||
scores := map[string]int64{"low": -5_000, "mid": 2_000, "high": 50_000}
|
||||
ids := map[string]int64{}
|
||||
for name, score := range scores {
|
||||
id := f.player(name, 100_000)
|
||||
ids[name] = id
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.RecordResult(f.ctx, tn.ID, id, score); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
board, err := f.svc.Leaderboard(f.ctx, tn.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(board) != 3 {
|
||||
t.Fatalf("board has %d entries, want 3", len(board))
|
||||
}
|
||||
if board[0].AccountID != ids["high"] {
|
||||
t.Fatalf("leader is %d, want %d", board[0].AccountID, ids["high"])
|
||||
}
|
||||
if board[2].AccountID != ids["low"] {
|
||||
t.Fatalf("last is %d, want %d", board[2].AccountID, ids["low"])
|
||||
}
|
||||
if board[0].Position != 1 {
|
||||
t.Fatalf("leader position = %d, want 1", board[0].Position)
|
||||
}
|
||||
}
|
||||
|
||||
// Scores accumulate across rounds, and losses count against you.
|
||||
func TestScoresAccumulateIncludingLosses(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(0, []int32{10000}, false)
|
||||
id := f.player("a", 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, n := range []int64{10_000, -3_000, 5_000, -1_000} {
|
||||
if err := f.svc.RecordResult(f.ctx, tn.ID, id, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
board, _ := f.svc.Leaderboard(f.ctx, tn.ID, 1)
|
||||
if board[0].ScoreMsat != 11_000 {
|
||||
t.Fatalf("score = %d, want 11000", board[0].ScoreMsat)
|
||||
}
|
||||
if board[0].RoundsPlayed != 4 {
|
||||
t.Fatalf("rounds = %d, want 4", board[0].RoundsPlayed)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- settlement ---------------- */
|
||||
|
||||
// The whole pool must be paid out. Integer division of a pool across shares
|
||||
// leaves a remainder, and a dropped remainder is money destroyed.
|
||||
func TestSettlementDistributesTheEntirePool(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// 3333/3333/3334 across a pool that does not divide evenly.
|
||||
tn := f.open(3_333, []int32{5000, 3000, 2000}, true)
|
||||
|
||||
var ids []int64
|
||||
for i := 0; i < 3; i++ {
|
||||
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||
ids = append(ids, id)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64((3-i)*1000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
poolBefore, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
before := make([]int64, len(ids))
|
||||
for i, id := range ids {
|
||||
before[i], _ = f.ledger.Balance(f.ctx, id)
|
||||
}
|
||||
|
||||
board, err := f.svc.Settle(f.ctx, tn.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var paid int64
|
||||
for i, id := range ids {
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
paid += after - before[i]
|
||||
}
|
||||
if paid != poolBefore.PoolMsat {
|
||||
t.Fatalf("paid out %d of a %d pool — %d msat vanished",
|
||||
paid, poolBefore.PoolMsat, poolBefore.PoolMsat-paid)
|
||||
}
|
||||
|
||||
after, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if after.PoolMsat != 0 {
|
||||
t.Fatalf("%d msat stranded in the pool after settlement", after.PoolMsat)
|
||||
}
|
||||
if board[0].PrizeMsat <= board[1].PrizeMsat {
|
||||
t.Fatalf("first place won %d, second %d", board[0].PrizeMsat, board[1].PrizeMsat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotSettleBeforeItEnds(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(1_000, []int32{10000}, false) // ends in an hour
|
||||
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrNotFinished) {
|
||||
t.Fatalf("got %v, want ErrNotFinished", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettlingTwiceIsRefused(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(1_000, []int32{10000}, true)
|
||||
id := f.player("a", 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||
}
|
||||
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||
if afterSecond != afterFirst {
|
||||
t.Fatalf("a second settlement paid again: %d -> %d", afterFirst, afterSecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Two instances settling at once must pay out exactly once.
|
||||
func TestConcurrentSettlementPaysOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(2_000, []int32{10000}, true)
|
||||
id := f.player("a", 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ok := make([]bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := f.svc.Settle(f.ctx, tn.ID)
|
||||
ok[i] = err == nil
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins := 0
|
||||
for _, v := range ok {
|
||||
if v {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after-before != 2_000 {
|
||||
t.Fatalf("player received %d, want the single 2000 pool", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBooksBalanceAfterSettlement(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(7_777, []int32{6000, 4000}, true)
|
||||
for i := 0; i < 4; i++ {
|
||||
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64(i)*100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := f.svc.Settle(f.ctx, tn.ID); 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 after tournament settlement: %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- cancellation ---------------- */
|
||||
|
||||
func TestCancelRefundsEveryEntrant(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(12_000, []int32{10000}, false)
|
||||
|
||||
var ids []int64
|
||||
var before []int64
|
||||
for i := 0; i < 4; i++ {
|
||||
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||
b, _ := f.ledger.Balance(f.ctx, id)
|
||||
before = append(before, b)
|
||||
ids = append(ids, id)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != before[i] {
|
||||
t.Fatalf("entrant %d has %d after cancellation, want their original %d",
|
||||
id, after, before[i])
|
||||
}
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.PoolMsat != 0 {
|
||||
t.Fatalf("%d msat stranded after cancellation", got.PoolMsat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelIsIdempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tn := f.open(1_000, []int32{10000}, false)
|
||||
id := f.player("a", 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
if err := f.svc.Cancel(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||
}
|
||||
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||
if afterSecond != afterFirst {
|
||||
t.Fatalf("a second cancellation refunded again: %d -> %d", afterFirst, afterSecond)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- lifecycle ---------------- */
|
||||
|
||||
func TestSchedulesAdvanceByClock(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
now := time.Now()
|
||||
tn, err := f.svc.Create(f.ctx, "later", "rocket", 0, []int32{10000}, nil,
|
||||
now.Add(-time.Minute), now.Add(-30*time.Second), now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||
if got.Status != tournament.StatusRunning {
|
||||
t.Fatalf("status = %s, want running once the start time has passed", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotEnterOnceRunning(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
now := time.Now()
|
||||
tn, err := f.svc.Create(f.ctx, "started", "rocket", 1_000, []int32{10000}, nil,
|
||||
now.Add(-time.Hour), now.Add(-time.Minute), now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := f.player("late", 100_000)
|
||||
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrNotRegistering) {
|
||||
t.Fatalf("got %v, want ErrNotRegistering", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user