Reviewing alby.go against real money found two bugs and one deployment hazard. The fee cap was accepted and ignored: maxFeeMsat appeared only in the signature. The Service sizes that cap against what the house will lose on routing and relies on the node refusing anything above it, so ignoring it turned a bounded cost into an unbounded one. It is now requested from Alby and checked again on the result. Amounts were truncated from millisatoshis to satoshis. A 1500 msat withdrawal debited 1500 and sent 1000, and the missing 500 was unaccounted — drift that surfaces weeks later as a books-do-not-balance alarm. Amounts that are not whole satoshis are now refused. The dev faucet and a real node could both be enabled. The faucet mints balance backed by nothing, so a player could withdraw it as real satoshis and drain the node. The server now refuses to start with both set. Withdrawal processing is also gated on solvency: paying the front of a queue while short leaves the players behind it with nothing. 11 tests against a mock of the Alby REST API covering auth, unit conversion, the fee cap, unsettled payments, error propagation, and context cancellation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1028 lines
31 KiB
Go
1028 lines
31 KiB
Go
// Command arcade is the Quantum Arcade server: one binary serving the API, the
|
|
// WebSocket round feed, and the static client.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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/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"
|
|
)
|
|
|
|
//go:embed static
|
|
var staticFiles embed.FS
|
|
|
|
// maxAutoCashOut bounds the target a client may request, keeping the
|
|
// conversion to fixed-point well inside the representable range.
|
|
const maxAutoCashOut = 1_000_000
|
|
|
|
// Games offered as shared rounds. They share one engine and differ in how the
|
|
// client renders the climb.
|
|
var games = []string{"rocket", "orbital", "tower"}
|
|
|
|
type server struct {
|
|
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
|
|
|
|
// Sessions live in Redis rather than instance memory. With several cloned
|
|
// instances behind one endpoint, a token issued by one must be accepted by
|
|
// all of them — otherwise every request would have to return to the
|
|
// instance that happened to handle the sign-in.
|
|
rdb *redis.Client
|
|
node *cluster.Node
|
|
|
|
// scratchNonce advances per play so each ticket has a distinct seed.
|
|
//
|
|
// It is drawn from Redis rather than a local counter: with several
|
|
// instances serving, two clones would otherwise hand the same nonce to
|
|
// different players, and identical nonces mean identical outcomes for the
|
|
// same key. The counter is shared, so every ticket is distinct fleet-wide.
|
|
}
|
|
|
|
func main() {
|
|
dsn := os.Getenv("ARCADE_DSN")
|
|
if dsn == "" {
|
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
|
}
|
|
addr := os.Getenv("ARCADE_ADDR")
|
|
if addr == "" {
|
|
addr = ":8080"
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
log.Fatalf("connecting to database: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
log.Fatalf("database unreachable: %v", err)
|
|
}
|
|
|
|
// Redis carries sessions and cluster coordination. Every cloned instance
|
|
// points at the same one; that plus the same database is the entire
|
|
// configuration a clone needs.
|
|
redisAddr := os.Getenv("ARCADE_REDIS")
|
|
if redisAddr == "" {
|
|
redisAddr = "localhost:6379"
|
|
}
|
|
rdb := redis.NewClient(&redis.Options{Addr: redisAddr})
|
|
defer rdb.Close()
|
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
|
log.Fatalf("redis unreachable at %s: %v", redisAddr, err)
|
|
}
|
|
|
|
// Every ticket in the catalog must have coherent odds before we serve it.
|
|
for _, t := range scratch.Catalog {
|
|
if err := t.Validate(); err != nil {
|
|
log.Fatalf("scratch catalog: %v", err)
|
|
}
|
|
}
|
|
|
|
l := ledger.New(pool)
|
|
s := &server{
|
|
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
|
|
// id and joins the cluster without anyone editing a file.
|
|
s.node = cluster.NewNode(rdb, advertiseAddr())
|
|
if err := s.node.Start(ctx); err != nil {
|
|
log.Fatalf("joining cluster: %v", err)
|
|
}
|
|
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)
|
|
log.Printf("Lightning node connected: %s", url)
|
|
// 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
|
|
// hands its rooms over without intervention.
|
|
for _, g := range games {
|
|
h := newGameHub(g, room.New(g, pool, s.ledger), s.node)
|
|
s.rooms[g] = h.room
|
|
s.hubs[g] = h
|
|
go h.supervise(ctx)
|
|
}
|
|
|
|
// 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(),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
log.Printf("Quantum Arcade listening on %s", addr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
log.Println("shutting down")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
func (s *server) routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
mux.HandleFunc("POST /api/auth/challenge", s.handleChallenge)
|
|
mux.HandleFunc("POST /api/auth/verify", s.handleVerify)
|
|
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)
|
|
mux.HandleFunc("GET /api/games", s.handleGames)
|
|
mux.HandleFunc("POST /api/bet", s.handleBet)
|
|
mux.HandleFunc("POST /api/cashout", s.handleCashout)
|
|
mux.HandleFunc("GET /api/scratch/catalog", s.handleScratchCatalog)
|
|
mux.HandleFunc("POST /api/scratch/play", s.handleScratchPlay)
|
|
mux.HandleFunc("GET /api/verify/{roundID}", s.handleVerifyRound)
|
|
|
|
// The faucet exists so the arcade is playable before Lightning is wired
|
|
// up. It mints from the bridge account exactly as a real deposit would,
|
|
// so the ledger path under test is the production one. Off by default.
|
|
if os.Getenv("ARCADE_DEV_FAUCET") == "1" {
|
|
log.Println("dev faucet ENABLED — funds are not backed by Lightning")
|
|
mux.HandleFunc("POST /api/dev/faucet", s.handleFaucet)
|
|
}
|
|
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 {
|
|
log.Fatalf("static assets: %v", err)
|
|
}
|
|
mux.Handle("/", http.FileServer(http.FS(sub)))
|
|
|
|
return logRequests(mux)
|
|
}
|
|
|
|
func logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
if r.URL.Path != "/api/health" {
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// SessionTTL bounds how long a token stays valid without use.
|
|
const SessionTTL = 24 * time.Hour
|
|
|
|
// session resolves the caller's public key from the Authorization header.
|
|
func (s *server) session(r *http.Request) (string, bool) {
|
|
token := bearer(r)
|
|
if token == "" {
|
|
return "", false
|
|
}
|
|
pk, err := s.rdb.Get(r.Context(), "qa:session:"+token).Result()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return pk, true
|
|
}
|
|
|
|
func bearer(r *http.Request) string {
|
|
token := r.Header.Get("Authorization")
|
|
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
|
return token[7:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// account resolves the caller to a ledger account id.
|
|
func (s *server) account(r *http.Request) (int64, []byte, bool) {
|
|
pkHex, ok := s.session(r)
|
|
if !ok {
|
|
return 0, nil, false
|
|
}
|
|
pk, err := hex.DecodeString(pkHex)
|
|
if err != nil {
|
|
return 0, nil, false
|
|
}
|
|
id, err := s.ledger.EnsurePlayer(r.Context(), pk)
|
|
if err != nil {
|
|
return 0, nil, false
|
|
}
|
|
return id, pk, true
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
total, err := s.ledger.ConservationCheck(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusServiceUnavailable, err.Error())
|
|
return
|
|
}
|
|
// A non-zero total means the books do not balance, which is a hard fault.
|
|
status := "ok"
|
|
if total != 0 {
|
|
status = "ledger_imbalance"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": status,
|
|
"ledger_sum_msat": total,
|
|
})
|
|
}
|
|
|
|
func (s *server) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Pubkey string `json:"pubkey"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
nonce, err := s.auth.Challenge(req.Pubkey)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"challenge": nonce})
|
|
}
|
|
|
|
func (s *server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Pubkey string `json:"pubkey"`
|
|
Signature string `json:"signature"`
|
|
Nickname string `json:"nickname"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
if err := s.auth.Verify(req.Pubkey, req.Signature); err != nil {
|
|
writeErr(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
pk, _ := hex.DecodeString(req.Pubkey)
|
|
accountID, err := s.ledger.EnsurePlayer(r.Context(), pk)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if req.Nickname != "" {
|
|
if _, err := s.pool.Exec(r.Context(),
|
|
`UPDATE accounts SET nickname = $2 WHERE id = $1`, accountID, req.Nickname); err != nil {
|
|
log.Printf("setting nickname: %v", err)
|
|
}
|
|
}
|
|
|
|
var tokenBytes [32]byte
|
|
seed := fair.NewServerSeed() // reuse the CSPRNG wrapper for session tokens
|
|
tokenBytes = seed.Bytes()
|
|
token := hex.EncodeToString(tokenBytes[:])
|
|
|
|
if err := s.rdb.Set(r.Context(), "qa:session:"+token, req.Pubkey, SessionTTL).Err(); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not store session")
|
|
return
|
|
}
|
|
|
|
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"token": token,
|
|
"balance_msat": bal,
|
|
})
|
|
}
|
|
|
|
func (s *server) handleBalance(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
bal, err := s.ledger.Balance(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
|
}
|
|
|
|
func (s *server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
entries, err := s.ledger.History(r.Context(), id, 50)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
|
}
|
|
|
|
func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
|
|
from, _, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
var req struct {
|
|
ToPubkey string `json:"to_pubkey"`
|
|
AmountMsat int64 `json:"amount_msat"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
to, err := hex.DecodeString(req.ToPubkey)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad recipient key")
|
|
return
|
|
}
|
|
toID, err := s.ledger.EnsurePlayer(r.Context(), to)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if _, err := s.ledger.Transfer(r.Context(), from, toID, req.AmountMsat); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
bal, _ := s.ledger.Balance(r.Context(), from)
|
|
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
|
|
// would report a game that never starts.
|
|
out := make([]json.RawMessage, 0, len(games))
|
|
for _, g := range games {
|
|
hub := s.hubs[g]
|
|
if frame, ok := hub.LastFrame(); ok {
|
|
out = append(out, json.RawMessage(frame))
|
|
continue
|
|
}
|
|
// Nothing seen yet — fall back to the local view, which is correct
|
|
// during the moment before the first frame arrives.
|
|
snap, err := json.Marshal(s.rooms[g].Snapshot())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, json.RawMessage(snap))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"rooms": out})
|
|
}
|
|
|
|
func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
|
id, pk, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "could not read request")
|
|
return
|
|
}
|
|
var req struct {
|
|
Game string `json:"game"`
|
|
StakeMsat int64 `json:"stake_msat"`
|
|
Nickname string `json:"nickname"`
|
|
// AutoCashOut is an optional target multiplier, e.g. 2.5 for 2.50x.
|
|
// Zero or absent means no target.
|
|
AutoCashOut float64 `json:"auto_cashout"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
rm, ok := s.rooms[req.Game]
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
// Only the instance driving this game holds the authoritative round.
|
|
if s.forwardToLeader(w, r, req.Game, body) {
|
|
return
|
|
}
|
|
// Convert the target to fixed-point at the boundary; everything past this
|
|
// point is integer arithmetic.
|
|
var target fixed.F
|
|
if req.AutoCashOut > 0 {
|
|
if req.AutoCashOut > float64(maxAutoCashOut) {
|
|
writeErr(w, http.StatusBadRequest, "auto cash-out target is too large")
|
|
return
|
|
}
|
|
target = fixed.F(req.AutoCashOut * float64(fixed.One))
|
|
}
|
|
|
|
if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat, target); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
|
}
|
|
|
|
func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "could not read request")
|
|
return
|
|
}
|
|
var req struct {
|
|
Game string `json:"game"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
rm, ok := s.rooms[req.Game]
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
if s.forwardToLeader(w, r, req.Game, body) {
|
|
return
|
|
}
|
|
at, err := rm.CashOut(id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"cashed_out_at": at.String()})
|
|
}
|
|
|
|
func (s *server) handleScratchCatalog(w http.ResponseWriter, r *http.Request) {
|
|
type entry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Blurb string `json:"blurb"`
|
|
Cells int `json:"cells"`
|
|
RTPBP uint64 `json:"rtp_bp"`
|
|
Odds []scratch.OddsRow `json:"odds"`
|
|
}
|
|
out := make([]entry, 0, len(scratch.Catalog))
|
|
for _, t := range scratch.Catalog {
|
|
out = append(out, entry{
|
|
ID: t.ID, Name: t.Name, Blurb: t.Blurb, Cells: t.Cells,
|
|
RTPBP: t.RTPBasisPoints(), Odds: t.Odds(),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tickets": out})
|
|
}
|
|
|
|
func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
|
id, pk, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
var req struct {
|
|
TicketID string `json:"ticket_id"`
|
|
StakeMsat int64 `json:"stake_msat"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed request")
|
|
return
|
|
}
|
|
ticket, ok := scratch.ByID(req.TicketID)
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "no such ticket")
|
|
return
|
|
}
|
|
if req.StakeMsat <= 0 {
|
|
writeErr(w, http.StatusBadRequest, "stake must be positive")
|
|
return
|
|
}
|
|
|
|
house, err := s.ledger.AccountByName(r.Context(), "house_pot")
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// Take the stake first: if the player cannot cover it, nothing else happens.
|
|
if _, err := s.ledger.Post(r.Context(), "scratch_stake", nil, []ledger.Posting{
|
|
{AccountID: id, AmountMsat: -req.StakeMsat},
|
|
{AccountID: house, AmountMsat: req.StakeMsat},
|
|
}); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
n, err := s.rdb.Incr(r.Context(), "qa:scratch:nonce").Result()
|
|
if err != nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "could not allocate a nonce")
|
|
return
|
|
}
|
|
nonce := uint64(n)
|
|
|
|
server := fair.NewServerSeed()
|
|
outcome, proof := scratch.PlayFromRound(ticket, server, pk, nonce, req.StakeMsat)
|
|
|
|
if outcome.PayoutMsat > 0 {
|
|
if _, err := s.ledger.Post(r.Context(), "scratch_payout", nil, []ledger.Posting{
|
|
{AccountID: house, AmountMsat: -outcome.PayoutMsat},
|
|
{AccountID: id, AmountMsat: outcome.PayoutMsat},
|
|
}); err != nil {
|
|
// The house cannot cover the prize. Record it and surface it
|
|
// rather than silently voiding a winning ticket.
|
|
log.Printf("scratch payout failed for account %d: %v", id, err)
|
|
writeErr(w, http.StatusInternalServerError, "house cannot cover this prize; stake refunded")
|
|
_, _ = s.ledger.Post(r.Context(), "scratch_refund", nil, []ledger.Posting{
|
|
{AccountID: house, AmountMsat: -req.StakeMsat},
|
|
{AccountID: id, AmountMsat: req.StakeMsat},
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
commitment := server.Commitment()
|
|
seedBytes := server.Bytes()
|
|
if _, err := s.pool.Exec(r.Context(),
|
|
`INSERT INTO scratch_plays
|
|
(account_id, ticket_id, nonce, commitment, server_seed,
|
|
stake_msat, tier_name, payout_msat, cells)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
id, ticket.ID, int64(nonce), commitment[:], seedBytes[:],
|
|
req.StakeMsat, outcome.TierName, outcome.PayoutMsat, outcome.Cells); err != nil {
|
|
log.Printf("recording scratch play: %v", err)
|
|
}
|
|
|
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"outcome": outcome,
|
|
"proof": proof,
|
|
"balance_msat": bal,
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
members, err := s.node.Members(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusServiceUnavailable, err.Error())
|
|
return
|
|
}
|
|
leaders := make(map[string]any, len(games))
|
|
for _, g := range games {
|
|
m, err := s.node.LeaderOf(r.Context(), g)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
leaders[g] = map[string]any{
|
|
"instance": m.ID,
|
|
"hostname": m.Hostname,
|
|
"address": m.Address,
|
|
"is_me": m.ID == s.node.ID,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"this_instance": map[string]string{
|
|
"id": s.node.ID, "hostname": s.node.Hostname, "address": s.node.Address,
|
|
},
|
|
"members": members,
|
|
"leaders": leaders,
|
|
})
|
|
}
|
|
|
|
// handleFaucet credits the caller from the bridge account. Development only.
|
|
func (s *server) handleFaucet(w http.ResponseWriter, r *http.Request) {
|
|
id, _, ok := s.account(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
|
return
|
|
}
|
|
var req struct {
|
|
AmountMsat int64 `json:"amount_msat"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountMsat <= 0 {
|
|
req.AmountMsat = 100_000_000 // 100k sats
|
|
}
|
|
if _, err := s.ledger.Deposit(r.Context(), id, req.AmountMsat); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
bal, _ := s.ledger.Balance(r.Context(), id)
|
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
|
}
|
|
|
|
// handleVerifyRound returns everything needed to check a settled round.
|
|
func (s *server) handleVerifyRound(w http.ResponseWriter, r *http.Request) {
|
|
idStr := r.PathValue("roundID")
|
|
roundID, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad round id")
|
|
return
|
|
}
|
|
|
|
var game string
|
|
var nonce int64
|
|
var commitment, serverSeed, clientSeed []byte
|
|
var crashPoint *int64
|
|
err = s.pool.QueryRow(r.Context(),
|
|
`SELECT game, nonce, commitment, server_seed, client_seed, crash_point
|
|
FROM rounds WHERE id = $1`, roundID).
|
|
Scan(&game, &nonce, &commitment, &serverSeed, &clientSeed, &crashPoint)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "round not found")
|
|
return
|
|
}
|
|
if serverSeed == nil {
|
|
writeErr(w, http.StatusConflict, "round has not settled yet; seed is still sealed")
|
|
return
|
|
}
|
|
|
|
rows, err := s.pool.Query(r.Context(),
|
|
`SELECT a.pubkey FROM bets b JOIN accounts a ON a.id = b.account_id
|
|
WHERE b.round_id = $1 ORDER BY b.id`, roundID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var participants []string
|
|
for rows.Next() {
|
|
var pk []byte
|
|
if err := rows.Scan(&pk); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
participants = append(participants, hex.EncodeToString(pk))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"round_id": roundID,
|
|
"game": game,
|
|
"nonce": nonce,
|
|
"commitment": hex.EncodeToString(commitment),
|
|
"server_seed": hex.EncodeToString(serverSeed),
|
|
"client_seed": hex.EncodeToString(clientSeed),
|
|
"crash_point": crashPoint,
|
|
"participants": participants,
|
|
"how_to_verify": "sha256(server_seed) must equal commitment; " +
|
|
"client_seed is sha256 over each participant pubkey length-prefixed in join order; " +
|
|
"round seed is hmac-sha256(server_seed, client_seed || big-endian nonce)",
|
|
})
|
|
}
|
|
|
|
// handleWS streams round snapshots to a client.
|
|
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
|
|
game := r.PathValue("game")
|
|
hub, ok := s.hubs[game]
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
rm := hub.room
|
|
|
|
// The server is LAN-only, so any origin on the local network is acceptable.
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.CloseNow()
|
|
|
|
ctx := r.Context()
|
|
// Frames come from the hub, which produces them when this instance leads
|
|
// the game and relays the leader's when it does not.
|
|
updates, unsubscribe := hub.Subscribe()
|
|
defer unsubscribe()
|
|
|
|
// Send the current state immediately so a joining phone is never blank.
|
|
first, err := json.Marshal(rm.Snapshot())
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := conn.Write(ctx, websocket.MessageText, first); err != nil {
|
|
return
|
|
}
|
|
|
|
// Frames arrive pre-serialised: the room marshals once per broadcast and
|
|
// every connection writes the same bytes. Marshalling per connection was
|
|
// the dominant cost under load.
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case payload, ok := <-updates:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|