Fixes the money bug flagged earlier. When an instance died mid-round its players had already been debited, so their stakes sat with the house: balanced books, quietly robbed players. Every instance now sweeps for unresolved rounds and refunds them. Such a round is marked void, not settled. The schema caught this: the reveal_is_complete constraint requires a settled round to publish its seed, and an abandoned round has no outcome to reveal. Void is a distinct state with its own column and a check that the two are exclusive. Claiming happens before money moves, so concurrent reconcilers on different instances refund exactly once. Adds TestFullPlayerJourney: sign-in with no account, fund, scratch, bet with an auto target, settle, verify the round independently, check the ledger history is continuous, transfer to a friend, and confirm the books still sum to zero. It asserts against the ledger rather than the API's own summary. Adds cmd/loadtest. One instance on 4 cores held 25,000 concurrent websocket connections with zero failures at 586MB RSS, about 26KB per connection, with the load generator competing for the same CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
577 lines
16 KiB
Go
577 lines
16 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:])
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|