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:]) } // An auto cash-out target must be accepted by the API and reflected in the // round, and an invalid one must be refused before any money moves. func TestAutoCashOutThroughTheAPI(t *testing.T) { c := newClient(t) c.signIn("autoplayer") start := c.fund(50_000_000) var out map[string]any // A target at or below 1.00 is meaningless and must be rejected. code := c.do("POST", "/api/bet", map[string]any{ "game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0, }, &out) if code == 200 { t.Fatal("a 1.00x auto cash-out target was accepted") } // An absurd target must be refused rather than overflowing the conversion. code = c.do("POST", "/api/bet", map[string]any{ "game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12, }, &out) if code == 200 { t.Fatal("an absurd auto cash-out target was accepted") } // Neither rejection may have moved money. var bal struct { BalanceMsat int64 `json:"balance_msat"` } c.do("GET", "/api/balance", nil, &bal) if bal.BalanceMsat != start { t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start) } // A sensible target should be accepted during a betting window. deadline := time.Now().Add(90 * time.Second) placed := false for time.Now().Before(deadline) && !placed { var games struct { Rooms []struct { 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" { var res map[string]any if code := c.do("POST", "/api/bet", map[string]any{ "game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 2.5, "nickname": "autoplayer", }, &res); code == 200 { placed = true } } } if !placed { time.Sleep(400 * time.Millisecond) } } if !placed { t.Fatal("could not place an auto cash-out bet within 90s") } } // The complete journey a real player takes, in one test: arrive with no // account, get funded, play both games, watch the ledger explain every change, // move sats to a friend, and verify a round independently. // // Each step asserts against the ledger rather than against the API's own // summary, so a bug that reports success while losing money fails here. func TestFullPlayerJourney(t *testing.T) { alice := newClient(t) bob := newClient(t) // 1. Arrive. No account exists; a keypair is the whole sign-up. alice.signIn("alice") bob.signIn("bob") var bal struct { BalanceMsat int64 `json:"balance_msat"` } alice.do("GET", "/api/balance", nil, &bal) if bal.BalanceMsat != 0 { t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat) } // 2. Get funded. const funded = 50_000_000 if got := alice.fund(funded); got != funded { t.Fatalf("balance after funding = %d, want %d", got, funded) } // 3. Scratch a ticket. The balance must move by exactly stake and payout. var sc struct { Outcome struct { TierName string `json:"tier_name"` PayoutMsat int64 `json:"payout_msat"` Cells []int `json:"cells"` } `json:"outcome"` Proof struct { Commitment string `json:"commitment"` ServerSeed string `json:"server_seed"` } `json:"proof"` BalanceMsat int64 `json:"balance_msat"` } const scratchStake = 1_000_000 if code := alice.do("POST", "/api/scratch/play", map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 { t.Fatalf("scratch play returned %d", code) } wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat if sc.BalanceMsat != wantAfterScratch { t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch) } // The scratch proof must verify against its own seed. seed, err := hex.DecodeString(sc.Proof.ServerSeed) if err != nil { t.Fatal(err) } sum := sha256.Sum256(seed) if hex.EncodeToString(sum[:]) != sc.Proof.Commitment { t.Fatal("scratch proof does not verify against its own commitment") } // 4. Play a crash round with an auto cash-out target. const stake = 2_000_000 var roundID int64 beforeRound := sc.BalanceMsat deadline := time.Now().Add(90 * time.Second) for time.Now().Before(deadline) && roundID == 0 { var games struct { Rooms []struct { RoundID int64 `json:"round_id"` Game string `json:"game"` State string `json:"state"` } `json:"rooms"` } alice.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"` } if code := alice.do("POST", "/api/bet", map[string]any{ "game": "rocket", "stake_msat": stake, "auto_cashout": 1.5, "nickname": "alice", }, &res); code == 200 { roundID = rm.RoundID // The stake must leave immediately, not at settlement. if res.BalanceMsat != beforeRound-stake { t.Fatalf("balance after bet = %d, want %d", res.BalanceMsat, beforeRound-stake) } } } if roundID == 0 { time.Sleep(400 * time.Millisecond) } } if roundID == 0 { t.Fatal("could not join a round within 90s") } // 5. Wait for settlement and check the outcome is consistent. var proof struct { Commitment string `json:"commitment"` ServerSeed string `json:"server_seed"` ClientSeed string `json:"client_seed"` Nonce int64 `json:"nonce"` CrashPoint *int64 `json:"crash_point"` Participants []string `json:"participants"` } settled := false deadline = time.Now().Add(120 * time.Second) for time.Now().Before(deadline) { if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 { settled = true break } time.Sleep(500 * time.Millisecond) } if !settled { t.Fatal("the round never settled") } // 6. Verify the round independently, the way the client does. roundSeed, err := hex.DecodeString(proof.ServerSeed) if err != nil { t.Fatal(err) } rs := sha256.Sum256(roundSeed) if hex.EncodeToString(rs[:]) != proof.Commitment { t.Fatal("settled round does not match its published commitment") } if proof.CrashPoint == nil { t.Fatal("a settled round published no crash point") } crash := float64(*proof.CrashPoint) / 4294967296.0 // 7. Balance must reflect the outcome exactly: paid at 1.5x if the round // reached the target, nothing otherwise. var after struct { BalanceMsat int64 `json:"balance_msat"` } // Settlement posts a moment after the reveal; poll briefly. wantWin := beforeRound - stake + stake*3/2 wantLose := beforeRound - stake ok := false for i := 0; i < 20; i++ { alice.do("GET", "/api/balance", nil, &after) if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose { ok = true break } time.Sleep(300 * time.Millisecond) } if !ok { t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome", after.BalanceMsat, wantWin, wantLose) } if crash >= 1.5 && after.BalanceMsat != wantWin { t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d", crash, after.BalanceMsat, wantWin) } if crash < 1.5 && after.BalanceMsat != wantLose { t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d", crash, after.BalanceMsat, wantLose) } // 8. Every balance change must be explained by the ledger. var hist struct { Entries []struct { Kind string `json:"Kind"` AmountMsat int64 `json:"AmountMsat"` BalanceBefore int64 `json:"BalanceBefore"` BalanceAfter int64 `json:"BalanceAfter"` } `json:"entries"` } alice.do("GET", "/api/history", nil, &hist) if len(hist.Entries) < 3 { t.Fatalf("history has %d entries; expected at least deposit, scratch, bet", len(hist.Entries)) } // History is newest-first; walking backwards, each entry's before must be // the previous entry's after. for i := 0; i < len(hist.Entries)-1; i++ { newer, older := hist.Entries[i], hist.Entries[i+1] if newer.BalanceBefore != older.BalanceAfter { t.Fatalf("ledger history is not continuous: %s starts at %d but the "+ "preceding %s ended at %d", newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter) } if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat { t.Fatalf("%s entry does not add up: %d + %d != %d", newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter) } } // 9. Send sats to a friend; both sides must move by the same amount. bobBefore := bob.fund(1) aliceBefore := after.BalanceMsat const gift = 500_000 var xfer struct { BalanceMsat int64 `json:"balance_msat"` } if code := alice.do("POST", "/api/transfer", map[string]any{ "to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift, }, &xfer); code != 200 { t.Fatalf("transfer returned %d", code) } if xfer.BalanceMsat != aliceBefore-gift { t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift) } var bobAfter struct { BalanceMsat int64 `json:"balance_msat"` } bob.do("GET", "/api/balance", nil, &bobAfter) if bobAfter.BalanceMsat != bobBefore+gift { t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift) } // 10. The books must still balance to zero after all of it. var health struct { Status string `json:"status"` LedgerSumMsat int64 `json:"ledger_sum_msat"` } alice.do("GET", "/api/health", nil, &health) if health.LedgerSumMsat != 0 { t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat) } }