Files
casino/cmd/arcade/e2e_test.go
drjones dee3becd47 fix(sim): cap crash point so extreme seeds cannot overflow or bankrupt
At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the
rarest and most valuable outcome silently became an instant 1.00x loss.
At u=2 it produced a 2.1-billion-times payout the house could never
cover, which would have left settlement failing and the player unpaid.
The crash point is now capped at the largest multiplier the curve can
express, which is unreachable anyway since the round hits its tick
ceiling first.

FromInt now panics outside the Q32.32 integer range instead of wrapping
a positive input into a negative value.

Raises coverage to 88% overall; adds a Makefile with db-reset, since the
append-only ledger steadily consumes bridge headroom across test runs.

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

281 lines
7.0 KiB
Go

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:])
}