Files
casino/cmd/arcade/main.go
drjones 2a2a1db8de feat: playable arcade — rooms, identity, client, deployment
Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.

Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:52 +00:00

630 lines
18 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"
"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/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
// 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"`
}
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
}
if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat); 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
}
}
}
}