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>
630 lines
18 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|