// 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) ── 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: 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 } } } }