// 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" "io/fs" "log" "net/http" "os" "os/signal" "strconv" "sync" "syscall" "time" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/drjones/quantum-arcade/pkg/fair" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/identity" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/room" "github.com/drjones/quantum-arcade/pkg/scratch" "github.com/jackc/pgx/v5/pgxpool" ) //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 // sessions maps a bearer token to a verified public key. Sessions live in // memory only: restarting the server signs everyone out, which is fine for // a machine you own and means there is no session store to leak. sessMu sync.RWMutex sessions map[string]string // scratchNonce advances per play so each ticket has a distinct seed. nonceMu sync.Mutex scratchNonce uint64 } 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) } // 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) } } s := &server{ pool: pool, ledger: ledger.New(pool), auth: identity.NewAuthenticator(), rooms: make(map[string]*room.Room), sessions: make(map[string]string), } for _, g := range games { r := room.New(g, pool, s.ledger) s.rooms[g] = r go func(r *room.Room) { if err := r.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { log.Printf("room %s stopped: %v", r.Game, err) } }(r) } 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("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) 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}) } // session resolves the caller's public key from the Authorization header. func (s *server) session(r *http.Request) (string, bool) { token := r.Header.Get("Authorization") if len(token) > 7 && token[:7] == "Bearer " { token = token[7:] } s.sessMu.RLock() defer s.sessMu.RUnlock() pk, ok := s.sessions[token] return pk, ok } // 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[:]) s.sessMu.Lock() s.sessions[token] = req.Pubkey s.sessMu.Unlock() 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}) } func (s *server) handleGames(w http.ResponseWriter, r *http.Request) { out := make([]room.Snapshot, 0, len(s.rooms)) for _, g := range games { out = append(out, s.rooms[g].Snapshot()) } 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 } 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.NewDecoder(r.Body).Decode(&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 } // 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 } var req struct { Game string `json:"game"` } if err := json.NewDecoder(r.Body).Decode(&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 } 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 } s.nonceMu.Lock() s.scratchNonce++ nonce := s.scratchNonce s.nonceMu.Unlock() 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, }) } // 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") rm, ok := s.rooms[game] if !ok { writeErr(w, http.StatusNotFound, "no such game") return } // 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() updates, unsubscribe := rm.Subscribe() defer unsubscribe() // Send the current state immediately so a joining phone is never blank. if err := wsjson.Write(ctx, conn, rm.Snapshot()); err != nil { return } for { select { case <-ctx.Done(): return case snap, ok := <-updates: if !ok { return } if err := wsjson.Write(ctx, conn, snap); err != nil { return } } } }