Auto cash-out closes a position at exactly the chosen target rather than the next tick's multiplier, and fires whenever the target is at or below the crash point. This is the feature that makes the game playable over a network, where manual timing is at the mercy of latency. House edge drops from 2% to 1% across crash and scratch. Scratch prize tables retuned so the published 99% RTP is exact. Adds docs/API.md: the client uses no private endpoints, so anyone can write a bot against the same API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
649 lines
18 KiB
Go
649 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/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
|
|
}
|
|
}
|
|
}
|
|
}
|