diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7d5d82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b911e85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build the server as a static binary with the client embedded in it, then ship +# it on a minimal base. The result is one file with no runtime dependencies. +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/arcade ./cmd/arcade + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/arcade /arcade +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/arcade"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee0251d --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Quantum Arcade + +A private physics arcade for one Linux box and the people on your network. +Drop-in crash rounds, instant scratch tickets, a real double-entry ledger, and +outcomes any player can verify on their own phone. + +## What it is + +Three shared crash games — a rocket fighting gravity, a decaying orbit, and a +stacking tower — running on a five-minute heartbeat, plus instant scratch +tickets to play between rounds. Identity is a keypair your browser generates; +there is no account, no email, and no password. + +Everything runs on one machine: one Go binary with the client embedded, +PostgreSQL, and Redis. + +## Running it + +```bash +docker compose up -d +``` + +Then open `http://:8080` from any phone on the network. + +To play with test funds before Lightning is wired up: + +```bash +ARCADE_DEV_FAUCET=1 docker compose up -d +``` + +The faucet mints through the same ledger path a real deposit uses, so the code +under test is the production code. Leave it off otherwise. + +## Development + +```bash +docker compose up -d postgres +go test ./... +go run ./cmd/arcade +``` + +End-to-end tests need a live server: + +```bash +ARCADE_DEV_FAUCET=1 go run ./cmd/arcade & +ARCADE_E2E=http://localhost:8080 go test ./cmd/arcade/ -v +``` + +## How fairness works + +Before betting opens, the server generates a random seed and publishes +`SHA-256(seed)`. It is now committed and cannot change its mind. + +The client seed is built from the public keys of everyone who joined the round. +The operator does not choose who plays, so it cannot steer the outcome even +knowing its own seed. + +The crash point is `HMAC-SHA256(serverSeed, clientSeed || nonce)`, run through +the simulation. After settlement the seed is published, and the Verify tab +recomputes the whole chain in your browser — it asks the server only for the +published values, never for a verdict. + +Scratch tickets use the identical pipeline, and their odds tables are generated +from the same data structure that produces outcomes, so the published odds +cannot drift from reality. A test asserts observed frequencies and empirical +return against the published figures across two million plays. + +## Architecture + +One binary, with enforced internal boundaries: + +| Package | Responsibility | +|---|---| +| `pkg/fixed` | Q32.32 fixed-point arithmetic; no floats, so results are identical everywhere | +| `pkg/sim` | Deterministic RNG and the crash curve | +| `pkg/fair` | Commit-reveal protocol and verification proofs | +| `pkg/ledger` | Append-only double-entry accounting | +| `pkg/scratch` | Scratch tickets and their published odds | +| `pkg/identity` | Keypair sign-in via signed challenge | +| `pkg/room` | Round lifecycle and live broadcast | + +Nine services on one machine would buy latency and 3am debugging, so this is +one process. Modules talk through interfaces only; extracting one into its own +service later is a transport change, not a rewrite. + +### Ledger invariants + +Enforced in the application and again by database constraints and triggers: + +- every transaction's postings sum to exactly zero +- no account may go negative, except the Lightning bridge, whose negative + balance is by definition what is owed to players +- rows are never updated or deleted; corrections are compensating entries + +`GET /api/health` sums every account. It must return zero. Anything else means +the books are corrupt. + +### Round timing + +The multiplier follows `m(t) = 1/(1 - t/T)²`, which diverges at exactly 60 +seconds. No round can run longer, however extreme the crash point, and the +climb visibly accelerates as it goes — which is where the tension comes from. + +## Status + +Built and tested: + +- fixed-point deterministic core, ledger, commit-reveal fairness +- three crash games with live multiplayer rounds +- two scratch tickets with verified-honest odds +- keypair identity, peer-to-peer transfers, transaction history +- in-browser verifier + +Not yet built: + +- **Lightning deposits and withdrawals.** The bridge account and ledger paths + exist; the node integration does not. The dev faucet stands in for now. +- Tournaments and scheduled events +- Operator dashboard + +## Scope + +This is built to run on a private network among people who know each other. +It is not hardened for, and should not be exposed to, the public internet. +Doing so would make it a public real-money gambling service, which carries +licensing, KYC, and AML obligations this codebase does not address. diff --git a/cmd/arcade/e2e_test.go b/cmd/arcade/e2e_test.go new file mode 100644 index 0000000..74679c4 --- /dev/null +++ b/cmd/arcade/e2e_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" +) + +// These tests drive a running server. Start it with: +// +// ARCADE_DEV_FAUCET=1 go run ./cmd/arcade +// +// and run with ARCADE_E2E=http://localhost:8080. They are skipped otherwise so +// that `go test ./...` stays green without a live server. +func baseURL(t *testing.T) string { + t.Helper() + u := os.Getenv("ARCADE_E2E") + if u == "" { + t.Skip("set ARCADE_E2E to run end-to-end tests") + } + return u +} + +type client struct { + t *testing.T + base string + token string + pub ed25519.PublicKey + priv ed25519.PrivateKey +} + +func newClient(t *testing.T) *client { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return &client{t: t, base: baseURL(t), pub: pub, priv: priv} +} + +func (c *client) do(method, path string, body, out any) int { + c.t.Helper() + var buf io.Reader + if body != nil { + b, _ := json.Marshal(body) + buf = bytes.NewReader(b) + } + req, err := http.NewRequest(method, c.base+path, buf) + if err != nil { + c.t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer res.Body.Close() + if out != nil { + _ = json.NewDecoder(res.Body).Decode(out) + } + return res.StatusCode +} + +func (c *client) signIn(nickname string) { + c.t.Helper() + pubHex := hex.EncodeToString(c.pub) + + var chal struct{ Challenge string } + if code := c.do("POST", "/api/auth/challenge", + map[string]string{"pubkey": pubHex}, &chal); code != 200 { + c.t.Fatalf("challenge failed: %d", code) + } + nonce, _ := hex.DecodeString(chal.Challenge) + sig := ed25519.Sign(c.priv, nonce) + + var res struct { + Token string `json:"token"` + } + if code := c.do("POST", "/api/auth/verify", map[string]string{ + "pubkey": pubHex, "signature": hex.EncodeToString(sig), "nickname": nickname, + }, &res); code != 200 { + c.t.Fatalf("verify failed: %d", code) + } + c.token = res.Token +} + +func (c *client) fund(msat int64) int64 { + c.t.Helper() + var res struct { + BalanceMsat int64 `json:"balance_msat"` + } + if code := c.do("POST", "/api/dev/faucet", + map[string]int64{"amount_msat": msat}, &res); code != 200 { + c.t.Fatalf("faucet failed: %d (is ARCADE_DEV_FAUCET=1 set?)", code) + } + return res.BalanceMsat +} + +func TestSignInAndFund(t *testing.T) { + c := newClient(t) + c.signIn("tester") + if bal := c.fund(50_000_000); bal < 50_000_000 { + t.Fatalf("balance after faucet = %d", bal) + } +} + +func TestUnauthenticatedRequestsRejected(t *testing.T) { + c := newClient(t) + var out map[string]any + if code := c.do("GET", "/api/balance", nil, &out); code != 401 { + t.Fatalf("unauthenticated balance returned %d, want 401", code) + } + if code := c.do("POST", "/api/bet", + map[string]any{"game": "rocket", "stake_msat": 1000}, &out); code != 401 { + t.Fatalf("unauthenticated bet returned %d, want 401", code) + } +} + +func TestCannotBetMoreThanBalance(t *testing.T) { + c := newClient(t) + c.signIn("broke") + // No faucet call: balance is zero. + var out map[string]any + code := c.do("POST", "/api/bet", + map[string]any{"game": "rocket", "stake_msat": 1_000_000}, &out) + if code != 400 { + t.Fatalf("betting without funds returned %d, want 400", code) + } +} + +// Play a full round: wait for a betting window, bet, and confirm the stake left +// the balance and the round eventually settles and reveals its seed. +func TestFullRoundLifecycleAndVerification(t *testing.T) { + c := newClient(t) + c.signIn("player") + c.fund(50_000_000) + + const stake = 1_000_000 + var roundID int64 + + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + var games struct { + Rooms []struct { + RoundID int64 `json:"round_id"` + Game string `json:"game"` + State string `json:"state"` + } `json:"rooms"` + } + c.do("GET", "/api/games", nil, &games) + + for _, rm := range games.Rooms { + if rm.Game != "rocket" || rm.State != "betting_open" { + continue + } + var res struct { + BalanceMsat int64 `json:"balance_msat"` + Error string `json:"error"` + } + if code := c.do("POST", "/api/bet", map[string]any{ + "game": "rocket", "stake_msat": stake, "nickname": "player", + }, &res); code == 200 { + roundID = rm.RoundID + } + } + if roundID != 0 { + break + } + time.Sleep(500 * time.Millisecond) + } + if roundID == 0 { + t.Fatal("never managed to place a bet within 90s") + } + + // Wait for the round to settle and expose its proof. + var proof struct { + Commitment string `json:"commitment"` + ServerSeed string `json:"server_seed"` + ClientSeed string `json:"client_seed"` + Nonce int64 `json:"nonce"` + Participants []string `json:"participants"` + } + settled := false + deadline = time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + if code := c.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 { + settled = true + break + } + time.Sleep(500 * time.Millisecond) + } + if !settled { + t.Fatal("round never settled") + } + + // The revealed seed must match the commitment published before betting. + seed, err := hex.DecodeString(proof.ServerSeed) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(seed) + if hex.EncodeToString(sum[:]) != proof.Commitment { + t.Fatalf("commitment mismatch:\n published %s\n actual %s", + proof.Commitment, hex.EncodeToString(sum[:])) + } + if len(proof.Participants) == 0 { + t.Fatal("settled round lists no participants") + } +} + +// The books must balance at all times, which the health endpoint reports. +func TestLedgerStaysBalanced(t *testing.T) { + c := newClient(t) + var health struct { + Status string `json:"status"` + LedgerSumMsat int64 `json:"ledger_sum_msat"` + } + if code := c.do("GET", "/api/health", nil, &health); code != 200 { + t.Fatalf("health returned %d", code) + } + if health.LedgerSumMsat != 0 { + t.Fatalf("ledger does not balance: sum = %d", health.LedgerSumMsat) + } + if health.Status != "ok" { + t.Fatalf("health status = %q", health.Status) + } +} + +func TestScratchTicketPlaysAndPays(t *testing.T) { + c := newClient(t) + c.signIn("scratcher") + start := c.fund(100_000_000) + + var res struct { + Outcome struct { + TierName string `json:"tier_name"` + PayoutMsat int64 `json:"payout_msat"` + Cells []int `json:"cells"` + } `json:"outcome"` + BalanceMsat int64 `json:"balance_msat"` + } + const stake = 1_000_000 + if code := c.do("POST", "/api/scratch/play", + map[string]any{"ticket_id": "nebula-nine", "stake_msat": stake}, &res); code != 200 { + t.Fatalf("scratch play returned %d", code) + } + if len(res.Outcome.Cells) != 9 { + t.Fatalf("got %d cells, want 9", len(res.Outcome.Cells)) + } + want := start - stake + res.Outcome.PayoutMsat + if res.BalanceMsat != want { + t.Fatalf("balance = %d, want %d (start %d, stake %d, payout %d)", + res.BalanceMsat, want, start, stake, res.Outcome.PayoutMsat) + } +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go new file mode 100644 index 0000000..9ede439 --- /dev/null +++ b/cmd/arcade/main.go @@ -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 + } + } + } +} diff --git a/cmd/arcade/static/app.js b/cmd/arcade/static/app.js new file mode 100644 index 0000000..cadaa6e --- /dev/null +++ b/cmd/arcade/static/app.js @@ -0,0 +1,636 @@ +/* Quantum Arcade client. + * + * Identity is an ed25519 keypair generated in the browser and kept in + * localStorage. There is no account to create and no password to lose. + * + * The verifier recomputes round outcomes locally with WebCrypto. It never asks + * the server whether a round was fair — it checks. + * + * All dynamic content is inserted with textContent or built as DOM nodes. + * Nothing that originates from another player (nicknames, keys) or from the + * server ever reaches innerHTML. */ + +const KEY_STORAGE = 'quantum-arcade-key'; +const NAME_STORAGE = 'quantum-arcade-name'; + +let keypair = null; // { publicKeyHex, privateKey (CryptoKey) } +let token = null; +let nickname = ''; +let stake = 5000; // millisatoshis +let currentGame = 'rocket'; +let socket = null; +let snapshot = null; +let myBet = null; // 'in' | 'out' | null + +const $ = (id) => document.getElementById(id); +const sats = (msat) => Math.round(msat / 1000).toLocaleString(); + +/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */ +function el(tag, attrs, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else node.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; +} + +function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +/* ---------------- identity ---------------- */ + +async function loadOrCreateKey() { + const stored = localStorage.getItem(KEY_STORAGE); + if (stored) { + const jwk = JSON.parse(stored); + const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']); + return { privateKey: priv, publicKeyHex: jwk.qa_pub }; + } + const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey)); + const pubHex = hex(rawPub); + const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey); + jwk.qa_pub = pubHex; + localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk)); + return { privateKey: kp.privateKey, publicKeyHex: pubHex }; +} + +function hex(bytes) { + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); +} +function unhex(s) { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16); + return out; +} + +async function signIn() { + nickname = ($('nickname').value || 'anon').trim().slice(0, 20); + localStorage.setItem(NAME_STORAGE, nickname); + + const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex }); + const sig = new Uint8Array(await crypto.subtle.sign( + { name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge))); + + const res = await api('POST', '/api/auth/verify', { + pubkey: keypair.publicKeyHex, + signature: hex(sig), + nickname, + }); + token = res.token; + + $('signin').hidden = true; + $('app').hidden = false; + $('balance-wrap').hidden = false; + setBalance(res.balance_msat); + $('pubkey').textContent = keypair.publicKeyHex; + + connect(currentGame); + loadScratch(); +} + +/* ---------------- api ---------------- */ + +async function api(method, path, body) { + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = 'Bearer ' + token; + const res = await fetch(path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; +} + +function setBalance(msat) { + $('balance').textContent = sats(msat); +} + +/* ---------------- crash room ---------------- */ + +function connect(game) { + if (socket) socket.close(); + currentGame = game; + myBet = null; + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + socket = new WebSocket(`${proto}://${location.host}/ws/${game}`); + socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data)); + socket.onclose = () => setTimeout(() => connect(currentGame), 1200); +} + +function onSnapshot(s) { + const roundChanged = !snapshot || snapshot.round_id !== s.round_id; + snapshot = s; + if (roundChanged) myBet = null; + + const mult = $('multiplier'); + mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×'; + mult.className = 'multiplier'; + + $('commitment').textContent = s.commitment || '—'; + $('revealed').textContent = s.server_seed || 'sealed until the round ends'; + + const action = $('action'); + const hint = $('hint'); + + switch (s.state) { + case 'betting_open': + $('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`; + action.textContent = myBet ? 'You are in — good luck' : 'Place bet'; + action.className = 'primary big'; + action.disabled = !!myBet; + break; + case 'locked': + $('state').textContent = 'launching'; + action.textContent = 'Launching…'; + action.disabled = true; + break; + case 'running': + $('state').textContent = 'in flight'; + if (myBet === 'in') { + const payout = stake * parseFloat(s.multiplier); + action.textContent = `Cash out ${sats(payout)}`; + action.className = 'primary big cashout'; + action.disabled = false; + } else { + action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching'; + action.className = 'primary big'; + action.disabled = true; + } + break; + case 'settled': + if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×'; + mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed'; + $('state').textContent = + `crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`; + action.textContent = 'Next round'; + action.className = 'primary big'; + action.disabled = true; + if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; } + break; + } + + renderPlayers(s.players); + draw(s); + if (s.state === 'running') tone(parseFloat(s.multiplier)); +} + +function renderPlayers(players) { + const wrap = $('players'); + clear(wrap); + for (const p of players || []) { + const amount = p.cashed_out + ? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×' + : sats(p.stake_msat) + ' sats'; + wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') }, + el('span', { class: 'who', text: p.nickname || 'anon' }), + el('span', { class: 'amt', text: amount }))); + } +} + +async function onAction() { + const hint = $('hint'); + hint.textContent = ''; + hint.className = 'hint'; + try { + if (snapshot.state === 'betting_open' && !myBet) { + const r = await api('POST', '/api/bet', { + game: currentGame, stake_msat: stake, nickname, + }); + setBalance(r.balance_msat); + myBet = 'in'; + } else if (snapshot.state === 'running' && myBet === 'in') { + const r = await api('POST', '/api/cashout', { game: currentGame }); + myBet = 'out'; + hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`; + hint.className = 'hint good'; + const b = await api('GET', '/api/balance'); + setBalance(b.balance_msat); + } + } catch (e) { + hint.textContent = e.message; + hint.className = 'hint bad'; + } +} + +/* ---------------- rendering ---------------- + * Each game draws the same climb differently: a rocket fighting gravity, a + * craft spiralling inward, or a tower stacking upward. */ + +const canvas = $('canvas'); +const ctx = canvas.getContext('2d'); +let stars = []; + +function sizeCanvas() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = canvas.clientWidth * dpr; + canvas.height = canvas.clientHeight * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stars = Array.from({ length: 70 }, () => ({ + x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3, + })); +} +window.addEventListener('resize', sizeCanvas); + +function draw(s) { + const w = canvas.clientWidth, h = canvas.clientHeight; + if (!w || !h) return; + ctx.clearRect(0, 0, w, h); + + const m = parseFloat(s.multiplier) || 1; + const crashed = s.state === 'settled'; + const progress = Math.min(1, Math.log(m) / Math.log(12)); + + // Starfield drifts downward as you climb. + ctx.fillStyle = '#ffffff'; + for (const st of stars) { + const y = (st.y + progress * 0.9) % 1; + ctx.globalAlpha = 0.10 + st.r * 0.16; + ctx.fillRect(st.x * w, y * h, st.r, st.r); + } + ctx.globalAlpha = 1; + + if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed); + else if (currentGame === 'tower') drawTower(w, h, progress, crashed); + else drawRocket(w, h, progress, crashed); +} + +function drawRocket(w, h, p, crashed) { + const x = w * 0.5; + const y = h * (0.88 - p * 0.66); + const accent = crashed ? '#ff4d6d' : '#38f2e4'; + + // Exhaust plume: longer and more agitated as the climb steepens. + const plume = 26 + p * 60; + const g = ctx.createLinearGradient(x, y, x, y + plume); + g.addColorStop(0, crashed ? '#ff4d6daa' : '#38f2e4cc'); + g.addColorStop(1, '#38f2e400'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(x - 7, y + 8); + ctx.lineTo(x + 7, y + 8); + ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.moveTo(x, y - 18); + ctx.lineTo(x + 9, y + 10); + ctx.lineTo(x - 9, y + 10); + ctx.closePath(); + ctx.fill(); + + if (crashed) { + ctx.strokeStyle = '#ff4d6d88'; + ctx.lineWidth = 2; + for (let i = 0; i < 9; i++) { + const a = (i / 9) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34); + ctx.stroke(); + } + } +} + +function drawOrbital(w, h, p, crashed) { + const cx = w / 2, cy = h / 2; + const planet = Math.min(w, h) * 0.16; + ctx.fillStyle = '#1d2757'; + ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill(); + + const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26; + ctx.strokeStyle = crashed ? '#ff4d6d55' : '#38f2e455'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke(); + + const a = p * Math.PI * 9; + const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit; + ctx.fillStyle = crashed ? '#ff4d6d' : '#38f2e4'; + ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); +} + +function drawTower(w, h, p, crashed) { + const blocks = Math.floor(p * 16) + 1; + const bw = w * 0.28, bh = h * 0.05; + for (let i = 0; i < blocks; i++) { + const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1); + const y = h * 0.9 - (i + 1) * bh; + ctx.fillStyle = i === blocks - 1 + ? (crashed ? '#ff4d6d' : '#38f2e4') + : `hsl(${230 + i * 4} 45% ${22 + i}%)`; + ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2); + } +} + +/* ---------------- ambient sound ---------------- + * Synthesised, so it never loops and ships no audio files. */ + +let audio = null; + +function toggleSound() { + if (audio) { + audio.close(); audio = null; + $('sound-toggle').classList.remove('on'); + return; + } + audio = new (window.AudioContext || window.webkitAudioContext)(); + const gain = audio.createGain(); + gain.gain.value = 0.05; + gain.connect(audio.destination); + + // Two detuned oscillators a fifth apart: calm, wide, and never resolving. + [55, 82.5].forEach((f) => { + const o = audio.createOscillator(); + o.type = 'sine'; + o.frequency.value = f; + const lfo = audio.createOscillator(); + lfo.frequency.value = 0.05 + Math.random() * 0.06; + const depth = audio.createGain(); + depth.gain.value = 1.5; + lfo.connect(depth).connect(o.frequency); + o.connect(gain); + o.start(); lfo.start(); + }); + $('sound-toggle').classList.add('on'); +} + +let lastTone = 0; +function tone(mult) { + if (!audio) return; + const now = audio.currentTime; + if (now - lastTone < 0.28) return; + lastTone = now; + const o = audio.createOscillator(); + const g = audio.createGain(); + o.type = 'triangle'; + o.frequency.value = 220 * Math.min(4, mult); + g.gain.setValueAtTime(0.0001, now); + g.gain.exponentialRampToValueAtTime(0.03, now + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); + o.connect(g).connect(audio.destination); + o.start(now); o.stop(now + 0.3); +} + +/* ---------------- scratch tickets ---------------- */ + +const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡']; + +async function loadScratch() { + const { tickets } = await api('GET', '/api/scratch/catalog'); + const wrap = $('tickets'); + clear(wrap); + + for (const t of tickets) { + const grid = el('div', { class: 'grid c' + t.cells }); + for (let i = 0; i < t.cells; i++) { + grid.appendChild(el('div', { class: 'cell', text: '?' })); + } + + const result = el('div', { class: 'result' }); + const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` }); + button.dataset.play = t.id; + button.onclick = () => playScratch(t, grid, result); + + const table = el('table', { class: 'odds' }, + el('tr', {}, + el('th', { text: 'prize' }), + el('th', { text: 'pays' }), + el('th', { text: 'chance' }))); + for (const o of t.odds) { + table.appendChild(el('tr', {}, + el('td', { text: o.tier }), + el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }), + el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' }))); + } + + wrap.appendChild(el('div', { class: 'card' }, + el('h3', { text: t.name }), + el('p', { class: 'muted small', text: t.blurb }), + grid, + result, + button, + table, + el('p', { class: 'small' }, + 'Return to player: ', + el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }), + '. These odds are read from the same table that generates results.'))); + } +} + +async function playScratch(t, grid, result) { + result.textContent = ''; + result.className = 'result'; + [...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; }); + + let out; + try { + out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake }); + } catch (e) { + result.textContent = e.message; + return; + } + setBalance(out.balance_msat); + + // Reveal cells one at a time — the outcome is already fixed, this is pacing. + const cells = out.outcome.cells; + const counts = {}; + cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1)); + const winner = Object.keys(counts).find((k) => counts[k] >= 3); + + cells.forEach((sym, i) => { + setTimeout(() => { + const cell = grid.children[i]; + cell.textContent = SYMBOLS[sym]; + cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : ''); + if (i === cells.length - 1) { + const won = out.outcome.payout_msat > 0; + result.className = 'result' + (won ? ' win' : ''); + result.textContent = won + ? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats` + : 'No win this time'; + } + }, i * 130); + }); +} + +/* ---------------- wallet ---------------- */ + +async function loadHistory() { + const { entries } = await api('GET', '/api/history'); + const wrap = $('history'); + clear(wrap); + for (const e of entries || []) { + wrap.appendChild(el('div', { class: 'entry' }, + el('span', { class: 'kind', text: e.Kind }), + el('span', { + class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'), + text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat), + }), + el('span', { class: 'after', text: sats(e.BalanceAfter) }))); + } +} + +async function sendSats() { + const hint = $('send-hint'); + try { + const r = await api('POST', '/api/transfer', { + to_pubkey: $('to-key').value.trim(), + amount_msat: Math.round(Number($('send-amt').value) * 1000), + }); + setBalance(r.balance_msat); + hint.textContent = 'Sent.'; + hint.className = 'hint good'; + loadHistory(); + } catch (e) { + hint.textContent = e.message; + hint.className = 'hint bad'; + } +} + +/* ---------------- verifier ---------------- + * Recomputed here, in the browser, from published values only. */ + +async function sha256(bytes) { + return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +} + +async function hmacSha256(keyBytes, msg) { + const key = await crypto.subtle.importKey( + 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg)); +} + +function concat(...arrays) { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { out.set(a, off); off += a.length; } + return out; +} + +function kvRow(label, value) { + return el('div', { class: 'kv' }, + el('span', { text: label }), + el('code', { text: value })); +} + +async function verifyRound() { + const out = $('verify-out'); + clear(out); + out.appendChild(el('p', { text: 'checking…' })); + + let r; + try { + r = await api('GET', '/api/verify/' + Number($('verify-id').value)); + } catch (e) { + clear(out); + out.appendChild(el('p', { class: 'bad', text: e.message })); + return; + } + + // 1. The revealed seed must hash to the commitment published beforehand. + const seedBytes = unhex(r.server_seed); + const seedHash = hex(await sha256(seedBytes)); + const commitOK = seedHash === r.commitment; + + // 2. The client seed must be the hash over participants, length-prefixed. + const parts = []; + for (const p of r.participants || []) { + const pk = unhex(p); + const len = new Uint8Array(4); + new DataView(len.buffer).setUint32(0, pk.length, false); + parts.push(len, pk); + } + const clientSeed = await sha256(concat(...parts)); + const clientOK = hex(clientSeed) === r.client_seed; + + // 3. The round seed follows from both, and determines the crash point. + const nonceBytes = new Uint8Array(8); + new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false); + const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes)); + + const allOK = commitOK && clientOK; + clear(out); + out.appendChild(el('p', { + class: allOK ? 'ok' : 'bad', + text: allOK ? '✓ This round checks out.' : '✗ Verification failed.', + })); + out.appendChild(kvRow('commitment', r.commitment)); + out.appendChild(kvRow('sha256(seed)', seedHash)); + out.appendChild(kvRow('client seed', r.client_seed || '')); + out.appendChild(kvRow('recomputed', hex(clientSeed))); + out.appendChild(kvRow('round seed', hex(roundSeed))); + out.appendChild(kvRow('crash point', + r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—')); + out.appendChild(el('p', { + class: 'muted small', + text: 'Computed on this device. The server was asked only for the published ' + + 'values, not for its opinion.', + })); +} + +/* ---------------- wiring ---------------- */ + +function selectTab(view) { + document.querySelectorAll('.tab').forEach((t) => + t.classList.toggle('active', t.dataset.view === view)); + document.querySelectorAll('.view').forEach((v) => + (v.hidden = v.id !== 'view-' + view)); + if (view === 'wallet') loadHistory(); +} + +function setStake(v) { + stake = v; + document.querySelectorAll('.chip').forEach((c) => + c.classList.toggle('on', Number(c.dataset.stake) === v)); + document.querySelectorAll('[data-play]').forEach((b) => { + b.textContent = `Scratch for ${sats(stake)} sats`; + }); +} + +async function init() { + sizeCanvas(); + keypair = await loadOrCreateKey(); + $('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…'; + $('nickname').value = localStorage.getItem(NAME_STORAGE) || ''; + + $('enter').onclick = () => signIn().catch((e) => { + $('keynote').textContent = e.message; + }); + $('action').onclick = onAction; + $('sound-toggle').onclick = toggleSound; + $('send').onclick = sendSats; + $('do-verify').onclick = verifyRound; + $('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex); + + document.querySelectorAll('.tab').forEach((t) => + (t.onclick = () => selectTab(t.dataset.view))); + document.querySelectorAll('.chip').forEach((c) => + (c.onclick = () => setStake(Number(c.dataset.stake)))); + + const pick = $('gamepick'); + [['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => { + const b = el('button', { text: label, class: id === currentGame ? 'on' : '' }); + b.onclick = () => { + document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on')); + b.classList.add('on'); + connect(id); + }; + pick.appendChild(b); + }); + + setStake(stake); +} + +init(); diff --git a/cmd/arcade/static/index.html b/cmd/arcade/static/index.html new file mode 100644 index 0000000..f9cb98b --- /dev/null +++ b/cmd/arcade/static/index.html @@ -0,0 +1,141 @@ + + + + + +Quantum Arcade + + + + + + + +
+
QUANTUMARCADE
+ + +
+ + +
+

Enter the arcade

+

+ No account, no email, no password. Your device generates a key that + is your identity. Keep the device, keep the balance. +

+ + +

+
+ +
+ + + + +
+
+ +
+ +
+
1.00×
+
connecting…
+
+
+ +
+
+ + + + + sats +
+ +
+
+ +
+ +
+ Fairness for this round +
commitment
+
revealed seedsealed until the round ends
+

+ The commitment is published before betting opens. The crash point is + derived from that seed combined with every player's key — so it cannot + be chosen after seeing who joined. +

+
+
+ + + + + + + + + + +
+ + + + diff --git a/cmd/arcade/static/style.css b/cmd/arcade/static/style.css new file mode 100644 index 0000000..74fe139 --- /dev/null +++ b/cmd/arcade/static/style.css @@ -0,0 +1,255 @@ +/* Quantum Arcade — obsidian and indigo, dense ornament, and vivid colour + reserved strictly for the things that matter: the multiplier, the balance, + and the button that takes your money out of danger. */ + +:root { + --void: #05060d; + --obsidian: #0a0c18; + --indigo: #131a3a; + --indigo-hi: #1d2757; + --ink: #c8cbe6; + --muted: #6a719c; + --cyan: #38f2e4; + --magenta: #ff3ec8; + --gold: #ffc857; + --danger: #ff4d6d; + --ornament: #1a2350; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +html, body { + margin: 0; + min-height: 100%; + background: radial-gradient(ellipse at 50% -10%, var(--indigo) 0%, var(--obsidian) 45%, var(--void) 100%); + color: var(--ink); + font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + overscroll-behavior: none; +} + +/* The ornament sits behind everything, busy but very low contrast. */ +.filigree { + position: fixed; inset: 0; + width: 100%; height: 100%; + color: var(--ornament); + opacity: 0.55; + pointer-events: none; + z-index: 0; +} + +body > * { position: relative; z-index: 1; } + +/* ---------- chrome ---------- */ + +.topbar { + display: flex; align-items: center; gap: 12px; + padding: 14px 16px calc(14px); + border-bottom: 1px solid #ffffff10; + backdrop-filter: blur(6px); +} + +.brand { + font-weight: 700; letter-spacing: 0.22em; font-size: 13px; + color: var(--ink); +} +.brand span { color: var(--cyan); margin-left: 6px; } + +.balance { margin-left: auto; text-align: right; line-height: 1.1; } +.balance .label { + display: block; font-size: 9px; letter-spacing: 0.2em; + text-transform: uppercase; color: var(--muted); +} +.balance .value { + font-variant-numeric: tabular-nums; + font-size: 18px; font-weight: 700; color: var(--cyan); + text-shadow: 0 0 18px #38f2e455; +} + +.sound { + background: none; border: 1px solid #ffffff20; color: var(--muted); + width: 34px; height: 34px; border-radius: 50%; font-size: 15px; +} +.sound.on { color: var(--cyan); border-color: var(--cyan); } + +/* ---------- panels ---------- */ + +.panel { + max-width: 460px; margin: 0 auto; padding: 40px 22px; +} +.center { text-align: center; } + +h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: 0.02em; } +h2 { font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--muted); margin: 0 0 10px; } + +.muted { color: var(--muted); } +.small { font-size: 12.5px; } +.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; } + +input { + width: 100%; padding: 13px 14px; margin: 8px 0; + background: #ffffff08; border: 1px solid #ffffff18; border-radius: 10px; + color: var(--ink); font-size: 16px; /* 16px stops iOS zooming on focus */ +} +input:focus { outline: none; border-color: var(--cyan); } + +button { + font: inherit; cursor: pointer; border-radius: 10px; + border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink); + padding: 11px 16px; +} +button:active { transform: translateY(1px); } + +.primary { + background: linear-gradient(135deg, var(--cyan), #21b6ff); + color: #04121a; border: none; font-weight: 700; letter-spacing: 0.04em; + box-shadow: 0 0 26px #38f2e444; + width: 100%; padding: 15px; +} +.primary.big { font-size: 17px; padding: 18px; } +.primary.cashout { + background: linear-gradient(135deg, var(--magenta), var(--gold)); + color: #1a0512; box-shadow: 0 0 34px #ff3ec866; + animation: urge 900ms ease-in-out infinite; +} +.primary:disabled { opacity: 0.4; box-shadow: none; animation: none; } + +@keyframes urge { + 0%, 100% { box-shadow: 0 0 26px #ff3ec855; } + 50% { box-shadow: 0 0 40px #ff3ec8aa; } +} + +/* ---------- tabs ---------- */ + +.tabs { + display: flex; gap: 6px; padding: 12px 12px 0; + max-width: 620px; margin: 0 auto; +} +.tab { + flex: 1; padding: 10px 4px; font-size: 12px; letter-spacing: 0.08em; + text-transform: uppercase; background: none; border: none; color: var(--muted); + border-bottom: 2px solid transparent; border-radius: 0; +} +.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); } + +.view { max-width: 620px; margin: 0 auto; padding: 14px 14px 60px; } + +/* ---------- crash stage ---------- */ + +.gamepick { display: flex; gap: 6px; margin-bottom: 12px; } +.gamepick button { + flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; + padding: 9px 4px; color: var(--muted); +} +.gamepick button.on { color: var(--cyan); border-color: var(--cyan); background: #38f2e412; } + +.stage { + position: relative; border-radius: 16px; overflow: hidden; + border: 1px solid #ffffff14; + background: linear-gradient(180deg, #070a16 0%, #05060d 100%); + aspect-ratio: 4 / 3; +} +#canvas { width: 100%; height: 100%; display: block; } + +.readout { + position: absolute; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; pointer-events: none; +} +.multiplier { + font-size: clamp(46px, 17vw, 88px); font-weight: 800; + font-variant-numeric: tabular-nums; letter-spacing: -0.02em; + color: var(--cyan); text-shadow: 0 0 40px #38f2e466; +} +.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; } +.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; } +.state { + font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; + color: var(--muted); margin-top: 6px; +} + +.controls { margin-top: 14px; } +.stakerow { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; } +.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 12px 4px; } +.chip.on { border-color: var(--cyan); color: var(--cyan); background: #38f2e414; } +.unit { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; } + +.hint { min-height: 18px; margin-top: 8px; font-size: 12px; color: var(--muted); text-align: center; } +.hint.bad { color: var(--danger); } +.hint.good { color: var(--gold); } + +.players { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; } +.player { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px; + font-size: 13px; +} +.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.player .amt { font-variant-numeric: tabular-nums; color: var(--muted); } +.player.out .amt { color: var(--gold); } + +.proof { margin-top: 18px; } +.proof summary { + cursor: pointer; font-size: 11px; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--muted); padding: 8px 0; +} +.kv { display: flex; gap: 10px; font-size: 11px; padding: 4px 0; } +.kv span { color: var(--muted); min-width: 96px; } +.kv code { word-break: break-all; color: var(--ink); opacity: 0.8; } + +/* ---------- scratch ---------- */ + +.card { + background: #ffffff07; border: 1px solid #ffffff14; + border-radius: 14px; padding: 16px; margin-bottom: 14px; +} +.card h3 { margin: 0 0 4px; font-size: 18px; } + +.grid { + display: grid; gap: 8px; margin: 14px 0; +} +.grid.c9 { grid-template-columns: repeat(3, 1fr); } +.grid.c6 { grid-template-columns: repeat(3, 1fr); } + +.cell { + aspect-ratio: 1; display: grid; place-items: center; + font-size: 26px; border-radius: 10px; + background: linear-gradient(140deg, var(--indigo-hi), var(--indigo)); + border: 1px solid #ffffff14; + transition: transform 160ms ease, background 260ms ease; +} +.cell.revealed { background: #05060d; border-color: #ffffff22; } +.cell.hit { border-color: var(--gold); box-shadow: 0 0 18px #ffc85755; } + +.odds { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; } +.odds th, .odds td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #ffffff10; } +.odds th { color: var(--muted); font-weight: 500; font-size: 10px; + letter-spacing: 0.12em; text-transform: uppercase; } +.odds td:last-child, .odds th:last-child { text-align: right; font-variant-numeric: tabular-nums; } +.rtp { color: var(--gold); font-weight: 700; } + +.result { text-align: center; padding: 10px 0; font-size: 16px; } +.result.win { color: var(--gold); font-weight: 700; } + +/* ---------- wallet ---------- */ + +.pubkey { + display: block; word-break: break-all; font-size: 11px; + background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0; + color: var(--muted); +} + +.history { display: flex; flex-direction: column; gap: 3px; } +.entry { + display: flex; gap: 10px; font-size: 12px; padding: 8px 10px; + background: #ffffff05; border-radius: 8px; +} +.entry .kind { flex: 1; color: var(--muted); } +.entry .delta { font-variant-numeric: tabular-nums; } +.entry .delta.pos { color: var(--cyan); } +.entry .delta.neg { color: var(--muted); } +.entry .after { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 74px; text-align: right; } + +.verify-out { margin-top: 12px; font-size: 12px; } +.verify-out .ok { color: var(--cyan); font-weight: 700; } +.verify-out .bad { color: var(--danger); font-weight: 700; } +.verify-out .kv code { font-size: 10px; } diff --git a/docker-compose.yml b/docker-compose.yml index ca1d427..d325424 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,19 @@ services: timeout: 3s retries: 20 + arcade: + build: . + environment: + ARCADE_DSN: postgres://arcade:arcade_dev@postgres:5432/arcade + ARCADE_ADDR: ":8080" + # Development funding. Leave unset in any real deployment. + ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}" + ports: ["8080:8080"] + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + restart: unless-stopped + volumes: pgdata: redisdata: diff --git a/go.mod b/go.mod index e665bc3..bb7d290 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.5 require github.com/jackc/pgx/v5 v5.10.0 require ( + github.com/coder/websocket v1.8.15 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index c0e505b..5895b3f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/migrations/0002_rounds.sql b/migrations/0002_rounds.sql new file mode 100644 index 0000000..2e1fddd --- /dev/null +++ b/migrations/0002_rounds.sql @@ -0,0 +1,58 @@ +-- Rounds, bets, and the verification record for every settled outcome. +-- +-- Seeds are stored so that any round can be re-verified indefinitely. The +-- server seed column is NULL until the round settles: revealing it early would +-- let a player compute the outcome before betting closes. + +CREATE TABLE rounds ( + id BIGSERIAL PRIMARY KEY, + game TEXT NOT NULL, -- 'rocket', 'orbital', 'tower' + nonce BIGINT NOT NULL, + commitment BYTEA NOT NULL, -- SHA-256 of the server seed + server_seed BYTEA, -- revealed only after settlement + client_seed BYTEA, -- derived from participants + crash_point BIGINT, -- Q32.32 fixed-point + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + locked_at TIMESTAMPTZ, + settled_at TIMESTAMPTZ, + CONSTRAINT reveal_is_complete CHECK ( + settled_at IS NULL OR (server_seed IS NOT NULL AND crash_point IS NOT NULL) + ) +); + +CREATE TABLE bets ( + id BIGSERIAL PRIMARY KEY, + round_id BIGINT NOT NULL REFERENCES rounds(id), + account_id BIGINT NOT NULL REFERENCES accounts(id), + stake_msat BIGINT NOT NULL CHECK (stake_msat > 0), + -- Set when the player cashes out; NULL means they rode it to the crash. + cashout_at BIGINT, -- Q32.32 multiplier + payout_msat BIGINT, + placed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + settled_at TIMESTAMPTZ, + UNIQUE (round_id, account_id) +); + +CREATE INDEX bets_round_idx ON bets (round_id); +CREATE INDEX bets_account_idx ON bets (account_id, id DESC); + +CREATE TABLE scratch_plays ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + ticket_id TEXT NOT NULL, + nonce BIGINT NOT NULL, + commitment BYTEA NOT NULL, + server_seed BYTEA NOT NULL, + stake_msat BIGINT NOT NULL CHECK (stake_msat > 0), + tier_name TEXT NOT NULL, + payout_msat BIGINT NOT NULL CHECK (payout_msat >= 0), + cells INTEGER[] NOT NULL, + played_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX scratch_account_idx ON scratch_plays (account_id, id DESC); + +-- Rounds and plays are historical records; they are never rewritten. +CREATE TRIGGER scratch_plays_append_only + BEFORE UPDATE OR DELETE ON scratch_plays + FOR EACH ROW EXECUTE FUNCTION reject_mutation(); diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go new file mode 100644 index 0000000..903b677 --- /dev/null +++ b/pkg/identity/identity.go @@ -0,0 +1,116 @@ +// Package identity implements keypair-based sign-in. +// +// There are no accounts in the usual sense: a player's ed25519 public key is +// their identity. To prove ownership they sign a server-issued challenge, which +// is single-use and short-lived. There is no password to leak, no email to +// verify, and nothing to reset — losing the key loses the balance, which is +// stated plainly in the interface. +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "sync" + "time" +) + +var ( + ErrUnknownChallenge = errors.New("identity: challenge not found or expired") + ErrBadSignature = errors.New("identity: signature does not verify") + ErrBadPublicKey = errors.New("identity: malformed public key") +) + +// ChallengeTTL is how long a challenge stays valid. Short, because a client +// signs it immediately. +const ChallengeTTL = 2 * time.Minute + +type challenge struct { + nonce [32]byte + expires time.Time +} + +// Authenticator issues and verifies sign-in challenges. +type Authenticator struct { + mu sync.Mutex + challenges map[string]challenge + now func() time.Time +} + +func NewAuthenticator() *Authenticator { + return &Authenticator{ + challenges: make(map[string]challenge), + now: time.Now, + } +} + +// Challenge issues a fresh nonce for a public key to sign. +func (a *Authenticator) Challenge(pubkeyHex string) (string, error) { + pk, err := ParsePublicKey(pubkeyHex) + if err != nil { + return "", err + } + var n [32]byte + if _, err := rand.Read(n[:]); err != nil { + panic("identity: system randomness unavailable: " + err.Error()) + } + + a.mu.Lock() + defer a.mu.Unlock() + a.sweepLocked() + a.challenges[hex.EncodeToString(pk)] = challenge{ + nonce: n, + expires: a.now().Add(ChallengeTTL), + } + return hex.EncodeToString(n[:]), nil +} + +// Verify checks a signature over the outstanding challenge for that key and +// consumes it, so a captured signature cannot be replayed. +func (a *Authenticator) Verify(pubkeyHex, signatureHex string) error { + pk, err := ParsePublicKey(pubkeyHex) + if err != nil { + return err + } + sig, err := hex.DecodeString(signatureHex) + if err != nil || len(sig) != ed25519.SignatureSize { + return ErrBadSignature + } + + a.mu.Lock() + key := hex.EncodeToString(pk) + c, ok := a.challenges[key] + if ok { + delete(a.challenges, key) // single use, whether or not it verifies + } + now := a.now() + a.mu.Unlock() + + if !ok || now.After(c.expires) { + return ErrUnknownChallenge + } + if !ed25519.Verify(pk, c.nonce[:], sig) { + return ErrBadSignature + } + return nil +} + +// sweepLocked drops expired challenges. Called under the mutex. +func (a *Authenticator) sweepLocked() { + now := a.now() + for k, c := range a.challenges { + if now.After(c.expires) { + delete(a.challenges, k) + } + } +} + +// ParsePublicKey decodes and validates a hex-encoded ed25519 public key. +func ParsePublicKey(s string) (ed25519.PublicKey, error) { + b, err := hex.DecodeString(s) + if err != nil || len(b) != ed25519.PublicKeySize { + return nil, ErrBadPublicKey + } + return ed25519.PublicKey(b), nil +} diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 0000000..9ded1de --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,86 @@ +package identity_test + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "testing" + + "github.com/drjones/quantum-arcade/pkg/identity" +) + +func newKey(t *testing.T) (string, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(pub), priv +} + +func TestValidSignatureAuthenticates(t *testing.T) { + a := identity.NewAuthenticator() + pubHex, priv := newKey(t) + + nonceHex, err := a.Challenge(pubHex) + if err != nil { + t.Fatal(err) + } + nonce, _ := hex.DecodeString(nonceHex) + sig := ed25519.Sign(priv, nonce) + + if err := a.Verify(pubHex, hex.EncodeToString(sig)); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } +} + +func TestWrongKeyCannotAuthenticate(t *testing.T) { + a := identity.NewAuthenticator() + pubHex, _ := newKey(t) + _, otherPriv := newKey(t) + + nonceHex, _ := a.Challenge(pubHex) + nonce, _ := hex.DecodeString(nonceHex) + sig := ed25519.Sign(otherPriv, nonce) + + if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrBadSignature) { + t.Fatalf("got %v, want ErrBadSignature", err) + } +} + +// A captured signature must not work twice. +func TestChallengeIsSingleUse(t *testing.T) { + a := identity.NewAuthenticator() + pubHex, priv := newKey(t) + + nonceHex, _ := a.Challenge(pubHex) + nonce, _ := hex.DecodeString(nonceHex) + sigHex := hex.EncodeToString(ed25519.Sign(priv, nonce)) + + if err := a.Verify(pubHex, sigHex); err != nil { + t.Fatal(err) + } + if err := a.Verify(pubHex, sigHex); !errors.Is(err, identity.ErrUnknownChallenge) { + t.Fatalf("replay succeeded or gave %v, want ErrUnknownChallenge", err) + } +} + +func TestMalformedKeyRejected(t *testing.T) { + a := identity.NewAuthenticator() + if _, err := a.Challenge("not-hex"); !errors.Is(err, identity.ErrBadPublicKey) { + t.Fatalf("got %v, want ErrBadPublicKey", err) + } + if _, err := a.Challenge("aabb"); !errors.Is(err, identity.ErrBadPublicKey) { + t.Fatalf("short key: got %v, want ErrBadPublicKey", err) + } +} + +func TestVerifyWithoutChallengeFails(t *testing.T) { + a := identity.NewAuthenticator() + pubHex, priv := newKey(t) + sig := ed25519.Sign(priv, []byte("anything")) + if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrUnknownChallenge) { + t.Fatalf("got %v, want ErrUnknownChallenge", err) + } +} diff --git a/pkg/room/room.go b/pkg/room/room.go new file mode 100644 index 0000000..ec7bdf5 --- /dev/null +++ b/pkg/room/room.go @@ -0,0 +1,446 @@ +// Package room runs the shared crash rounds. +// +// A round moves through four states: betting_open, locked, running, settled. +// The server seed is committed before betting opens and revealed only at +// settlement, so no one — including the operator — can know the crash point +// while bets are still being placed. +// +// All money movement goes through the ledger in a single transaction per +// settlement, which is what keeps the books balanced under load. +package room + +import ( + "context" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/fixed" + "github.com/drjones/quantum-arcade/pkg/ledger" + "github.com/drjones/quantum-arcade/pkg/sim" + "github.com/jackc/pgx/v5/pgxpool" +) + +// State is the phase of a round. +type State string + +const ( + StateBetting State = "betting_open" + StateLocked State = "locked" + StateRunning State = "running" + StateSettled State = "settled" +) + +// Timings. The betting window is deliberately generous: at a party, people are +// walking up to their phones mid-round. +const ( + BettingWindow = 20 * time.Second + LockedPause = 3 * time.Second + SettledPause = 7 * time.Second + TickInterval = time.Second / sim.TickHz +) + +// Bet is one player's position in the current round. +type Bet struct { + AccountID int64 + Pubkey []byte + Nickname string + StakeMsat int64 + CashedOutAt fixed.F // zero until they cash out + PayoutMsat int64 +} + +// Snapshot is what clients render. It carries the seed inputs so a client can +// verify the round the moment it settles. +type Snapshot struct { + RoundID int64 `json:"round_id"` + Game string `json:"game"` + State State `json:"state"` + Tick int `json:"tick"` + Multiplier string `json:"multiplier"` + Commitment string `json:"commitment"` + ServerSeed string `json:"server_seed,omitempty"` // only once settled + CrashPoint string `json:"crash_point,omitempty"` // only once settled + Players []Player `json:"players"` + HousePotMsat int64 `json:"house_pot_msat"` + NextPhaseIn float64 `json:"next_phase_in_seconds"` +} + +// Player is the public view of a participant. +type Player struct { + Nickname string `json:"nickname"` + PubkeyHex string `json:"pubkey"` + StakeMsat int64 `json:"stake_msat"` + CashedOut string `json:"cashed_out,omitempty"` + PayoutMsat int64 `json:"payout_msat"` +} + +// Room runs one game's round loop. +type Room struct { + Game string + + pool *pgxpool.Pool + ledger *ledger.Ledger + + mu sync.RWMutex + roundID int64 + state State + tick int + nonce uint64 + serverSeed fair.ServerSeed + commitment [32]byte + crashPoint fixed.F + bets map[int64]*Bet + order [][]byte // participant pubkeys in join order + phaseEnds time.Time + + subscribers map[chan Snapshot]struct{} + subMu sync.Mutex +} + +func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room { + return &Room{ + Game: game, + pool: pool, + ledger: l, + state: StateSettled, + bets: make(map[int64]*Bet), + subscribers: make(map[chan Snapshot]struct{}), + phaseEnds: time.Now(), + } +} + +// Subscribe returns a channel of snapshots. The channel is buffered and drops +// updates rather than blocking the round loop: a slow phone must never stall +// the game for everyone else. +func (r *Room) Subscribe() (<-chan Snapshot, func()) { + ch := make(chan Snapshot, 8) + r.subMu.Lock() + r.subscribers[ch] = struct{}{} + r.subMu.Unlock() + + return ch, func() { + r.subMu.Lock() + delete(r.subscribers, ch) + close(ch) + r.subMu.Unlock() + } +} + +func (r *Room) broadcast() { + snap := r.Snapshot() + r.subMu.Lock() + defer r.subMu.Unlock() + for ch := range r.subscribers { + select { + case ch <- snap: + default: // subscriber is behind; skip this frame + } + } +} + +// Run drives the round loop until the context is cancelled. +func (r *Room) Run(ctx context.Context) error { + ticker := time.NewTicker(TickInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if err := r.step(ctx); err != nil { + // A failed step must not kill the room; log-and-continue keeps + // the arcade running even if the database blips. + fmt.Printf("room %s: step error: %v\n", r.Game, err) + } + } + } +} + +func (r *Room) step(ctx context.Context) error { + r.mu.Lock() + state, phaseEnds := r.state, r.phaseEnds + r.mu.Unlock() + + now := time.Now() + + switch state { + case StateSettled: + if now.After(phaseEnds) { + return r.openRound(ctx) + } + case StateBetting: + if now.After(phaseEnds) { + r.mu.Lock() + r.state = StateLocked + r.phaseEnds = now.Add(LockedPause) + r.mu.Unlock() + r.broadcast() + } + case StateLocked: + if now.After(phaseEnds) { + return r.startRunning(ctx) + } + case StateRunning: + r.mu.Lock() + r.tick++ + reached := sim.MultiplierAt(r.tick) + // A crash point beyond what the curve expresses would otherwise never + // be reached, so the tick ceiling also ends the round. + crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks + r.mu.Unlock() + + if crashed { + return r.settle(ctx) + } + r.broadcast() + } + return nil +} + +// openRound commits to a fresh server seed and opens betting. +func (r *Room) openRound(ctx context.Context) error { + seed := fair.NewServerSeed() + commitment := seed.Commitment() + + r.mu.Lock() + r.nonce++ + nonce := r.nonce + r.mu.Unlock() + + var roundID int64 + err := r.pool.QueryRow(ctx, + `INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`, + r.Game, int64(nonce), commitment[:]).Scan(&roundID) + if err != nil { + return fmt.Errorf("opening round: %w", err) + } + + r.mu.Lock() + r.roundID = roundID + r.serverSeed = seed + r.commitment = commitment + r.crashPoint = 0 + r.tick = 0 + r.bets = make(map[int64]*Bet) + r.order = nil + r.state = StateBetting + r.phaseEnds = time.Now().Add(BettingWindow) + r.mu.Unlock() + + r.broadcast() + return nil +} + +// startRunning derives the crash point from the committed seed and the +// participant set, then begins the climb. +func (r *Room) startRunning(ctx context.Context) error { + r.mu.Lock() + clientSeed := fair.ClientSeed(r.order) + roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce) + r.crashPoint = sim.CrashPoint(roundSeed) + r.state = StateRunning + r.tick = 0 + roundID := r.roundID + crash := r.crashPoint + r.mu.Unlock() + + if _, err := r.pool.Exec(ctx, + `UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3 + WHERE id = $1`, + roundID, clientSeed[:], int64(crash)); err != nil { + return fmt.Errorf("locking round: %w", err) + } + + r.broadcast() + return nil +} + +// settle pays out everyone who cashed out in time and reveals the seed. +// Payouts are written as one ledger transaction so the books cannot be left +// half-updated. +func (r *Room) settle(ctx context.Context) error { + r.mu.Lock() + roundID := r.roundID + seed := r.serverSeed + crash := r.crashPoint + bets := make([]*Bet, 0, len(r.bets)) + for _, b := range r.bets { + bets = append(bets, b) + } + r.state = StateSettled + r.phaseEnds = time.Now().Add(SettledPause) + r.mu.Unlock() + + house, err := r.ledger.AccountByName(ctx, "house_pot") + if err != nil { + return err + } + + var postings []ledger.Posting + var housePays int64 + for _, b := range bets { + if b.CashedOutAt == 0 { + continue // rode it into the crash; the stake already sits with the house + } + payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One) + b.PayoutMsat = payout + if payout > 0 { + postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout}) + housePays += payout + } + if _, err := r.pool.Exec(ctx, + `UPDATE bets SET payout_msat = $2, settled_at = now() + WHERE round_id = $1 AND account_id = $3`, + roundID, payout, b.AccountID); err != nil { + return fmt.Errorf("recording payout: %w", err) + } + } + + if housePays > 0 { + postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays}) + rid := roundID + if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil { + return fmt.Errorf("settling round %d: %w", roundID, err) + } + } + + // pgx encodes byte slices, not fixed-size arrays, so the seed is sliced. + seedBytes := seed.Bytes() + if _, err := r.pool.Exec(ctx, + `UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`, + roundID, seedBytes[:]); err != nil { + return fmt.Errorf("revealing seed: %w", err) + } + + _ = crash + r.broadcast() + return nil +} + +// PlaceBet takes a stake during the betting window. The stake moves to the +// house immediately, so a player can never bet money they do not have. +func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64) error { + if stakeMsat <= 0 { + return ledger.ErrNonPositiveAmount + } + + r.mu.Lock() + if r.state != StateBetting { + r.mu.Unlock() + return fmt.Errorf("betting is closed") + } + if _, exists := r.bets[accountID]; exists { + r.mu.Unlock() + return fmt.Errorf("already in this round") + } + roundID := r.roundID + r.mu.Unlock() + + house, err := r.ledger.AccountByName(ctx, "house_pot") + if err != nil { + return err + } + rid := roundID + if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{ + {AccountID: accountID, AmountMsat: -stakeMsat}, + {AccountID: house, AmountMsat: stakeMsat}, + }); err != nil { + return err + } + + if _, err := r.pool.Exec(ctx, + `INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`, + roundID, accountID, stakeMsat); err != nil { + return err + } + + r.mu.Lock() + // Re-check state: the window may have closed while we were in the database. + if r.state != StateBetting || r.roundID != roundID { + r.mu.Unlock() + return fmt.Errorf("betting closed while placing bet") + } + r.bets[accountID] = &Bet{ + AccountID: accountID, Pubkey: pubkey, + Nickname: nickname, StakeMsat: stakeMsat, + } + r.order = append(r.order, pubkey) + r.mu.Unlock() + + r.broadcast() + return nil +} + +// CashOut locks in the current multiplier. It is rejected once the round has +// passed the crash point, which the tick loop enforces by settling first. +func (r *Room) CashOut(accountID int64) (fixed.F, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.state != StateRunning { + return 0, fmt.Errorf("round is not running") + } + b, ok := r.bets[accountID] + if !ok { + return 0, fmt.Errorf("no bet in this round") + } + if b.CashedOutAt != 0 { + return 0, fmt.Errorf("already cashed out") + } + at := sim.MultiplierAt(r.tick) + if at >= r.crashPoint { + return 0, fmt.Errorf("too late") + } + b.CashedOutAt = at + + go func() { + if _, err := r.pool.Exec(context.Background(), + `UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`, + r.roundID, accountID, int64(at)); err != nil { + fmt.Printf("room %s: recording cashout: %v\n", r.Game, err) + } + }() + + return at, nil +} + +// Snapshot renders the current state for clients. +func (r *Room) Snapshot() Snapshot { + r.mu.RLock() + defer r.mu.RUnlock() + + players := make([]Player, 0, len(r.bets)) + for _, b := range r.bets { + p := Player{ + Nickname: b.Nickname, + PubkeyHex: hex.EncodeToString(b.Pubkey), + StakeMsat: b.StakeMsat, + PayoutMsat: b.PayoutMsat, + } + if b.CashedOutAt != 0 { + p.CashedOut = b.CashedOutAt.String() + } + players = append(players, p) + } + + s := Snapshot{ + RoundID: r.roundID, + Game: r.Game, + State: r.state, + Tick: r.tick, + Multiplier: sim.MultiplierAt(r.tick).String(), + Commitment: hex.EncodeToString(r.commitment[:]), + Players: players, + NextPhaseIn: time.Until(r.phaseEnds).Seconds(), + } + // The seed is revealed only once the round is over. + if r.state == StateSettled && r.crashPoint != 0 { + s.ServerSeed = r.serverSeed.Hex() + s.CrashPoint = r.crashPoint.String() + } + return s +} diff --git a/pkg/sim/crash.go b/pkg/sim/crash.go index b672a22..491ea8b 100644 --- a/pkg/sim/crash.go +++ b/pkg/sim/crash.go @@ -12,10 +12,17 @@ const HouseEdgeBP int64 = 200 // TickHz is the simulation rate. Rounds advance in whole ticks only. const TickHz = 60 -// growthPerTickBP is multiplier growth per tick, in basis points of the current -// value. At 6bp and 60Hz the multiplier reaches 2x in roughly 19 seconds, which -// is long enough to feel the climb and short enough to keep rounds moving. -const growthPerTickBP int64 = 6 +// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz. +// +// The multiplier follows a hyperbolic curve that diverges at exactly this +// tick, so no round can run longer no matter how extreme the crash point. +// An exponential curve has no such bound — a 275x round on one takes over two +// and a half minutes, which is unplayable when a dozen people are waiting. +const RoundTicks = 60 * TickHz + +// MaxMultiplier is the largest value the curve expresses, reached on the final +// tick. Crash points at or above it settle when the round hits its ceiling. +func MaxMultiplier() fixed.F { return MultiplierAt(RoundTicks - 1) } // CrashPoint derives the multiplier at which a round ends, as a pure function of // the seed. @@ -48,31 +55,49 @@ func CrashPoint(seed [32]byte) fixed.F { return cp } -// step is the per-tick growth factor, 1 + growthPerTickBP/10000, in Q32.32. -func step() fixed.F { - return fixed.One + fixed.F(growthPerTickBP<<32/10000) -} - -// MultiplierAt returns the multiplier displayed at a given tick of the round, -// compounding from 1.0. +// MultiplierAt returns the multiplier displayed at a given tick. +// +// m(t) = 1 / (1 - t/T)^2 +// +// It starts at 1.0, rises slowly at first, and accelerates without bound as t +// approaches T. That acceleration is the tension: the longer you hold, the +// faster the number moves away from you, and the less time you have to react. +// It is also O(1), so a long round costs no more per tick than a short one. func MultiplierAt(tick int) fixed.F { - m := fixed.One - s := step() - for i := 0; i < tick; i++ { - m = m.Mul(s) + if tick <= 0 { + return fixed.One } - return m + // Clamp the tick, not the value: clamping the value would make the curve + // step backwards at the boundary if rounding put the last computed point + // above the nominal ceiling. + if tick >= RoundTicks { + tick = RoundTicks - 1 + } + // remaining = 1 - tick/T, always in (0, 1]. + remaining := fixed.One - fixed.FromInt(int64(tick)).Div(fixed.FromInt(RoundTicks)) + return fixed.One.Div(remaining.Mul(remaining)) } -// TicksToMultiplier returns the first tick at which MultiplierAt reaches m. +// TicksToMultiplier returns the first tick at which MultiplierAt reaches m, +// inverting the curve: t = T * (1 - 1/sqrt(m)). func TicksToMultiplier(m fixed.F) int { - cur := fixed.One - s := step() - for tick := 0; tick < 1_000_000; tick++ { - if cur >= m { - return tick - } - cur = cur.Mul(s) + if m <= fixed.One { + return 0 } - return 1_000_000 + if m >= MaxMultiplier() { + return RoundTicks + } + inv := fixed.One.Div(fixed.Sqrt(m)) + t := fixed.FromInt(RoundTicks).Mul(fixed.One - inv).Int() + + // Rounding in fixed point can land a tick early; step forward to the first + // tick that genuinely reaches the target. + tick := int(t) + for tick > 0 && MultiplierAt(tick-1) >= m { + tick-- + } + for tick < RoundTicks && MultiplierAt(tick) < m { + tick++ + } + return tick } diff --git a/pkg/sim/crash_test.go b/pkg/sim/crash_test.go index 44c46ab..df5d2e2 100644 --- a/pkg/sim/crash_test.go +++ b/pkg/sim/crash_test.go @@ -95,3 +95,33 @@ func TestTicksToMultiplierRoundTrips(t *testing.T) { } } } + +// No round may outlast the ceiling, however extreme the crash point. +func TestRoundLengthIsBounded(t *testing.T) { + if got := MultiplierAt(RoundTicks); got != MaxMultiplier() { + t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier()) + } + // Even the most extreme crash point settles within the ceiling. + worst := fixed.FromInt(4_000_000_000) + if tick := TicksToMultiplier(worst); tick > RoundTicks { + t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks) + } +} + +// Timings that matter for how the game feels. +func TestCurveTimings(t *testing.T) { + for _, c := range []struct { + multiplier int64 + maxSeconds float64 + }{ + {2, 20}, // the common case should arrive quickly + {10, 45}, + {100, 56}, + } { + tick := TicksToMultiplier(fixed.FromInt(c.multiplier)) + secs := float64(tick) / TickHz + if secs > c.maxSeconds { + t.Errorf("%dx takes %.1fs, want under %.0fs", c.multiplier, secs, c.maxSeconds) + } + } +}