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>
This commit is contained in:
629
cmd/arcade/main.go
Normal file
629
cmd/arcade/main.go
Normal file
@@ -0,0 +1,629 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user