diff --git a/pkg/ledger/edge_test.go b/pkg/ledger/edge_test.go new file mode 100644 index 0000000..35ff14a --- /dev/null +++ b/pkg/ledger/edge_test.go @@ -0,0 +1,232 @@ +package ledger_test + +import ( + "context" + "math" + "sync" + "testing" + + "github.com/drjones/quantum-arcade/pkg/ledger" +) + +// These tests attack the ledger with extreme values. Money code fails at the +// boundaries, so the boundaries are where it should be hit hardest. + +func TestHugeBalanceIsExact(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + p, err := l.EnsurePlayer(ctx, uniqueKey(t, "whale")) + if err != nil { + t.Fatal(err) + } + + // 21 million BTC in millisatoshis is the largest amount that can ever + // exist: 2.1e15. It must round-trip exactly, with no float contamination. + const allTheBitcoin int64 = 21_000_000 * 100_000_000 * 1000 + if _, err := l.Deposit(ctx, p, allTheBitcoin); err != nil { + t.Fatalf("depositing the entire supply: %v", err) + } + bal, err := l.Balance(ctx, p) + if err != nil { + t.Fatal(err) + } + if bal != allTheBitcoin { + t.Fatalf("balance = %d, want %d (off by %d)", bal, allTheBitcoin, bal-allTheBitcoin) + } +} + +// A deposit that would overflow int64 must be refused, not wrap around. +func TestOverflowingDepositIsRejected(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "overflow")) + + // A MaxInt64 deposit must be refused: it would underflow the bridge, whose + // balance is already negative by everything owed to players. + if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil { + t.Fatal("a MaxInt64 deposit was accepted") + } + if bal, _ := l.Balance(ctx, p); bal != 0 { + t.Fatalf("rejected deposit still moved the balance to %d", bal) + } +} + +// Credit overflow is structurally unreachable, and that is a stronger +// guarantee than the runtime guard. +// +// Every millisatoshi inside the system was issued by debiting the bridge, and +// the bridge cannot pass MinInt64. So the sum of all non-bridge balances is +// bounded by MaxInt64, and no individual account can be pushed past it by any +// sequence of balanced transactions. The guard in Post remains as defence in +// depth against a future issuance path that does not go through the bridge. +func TestIssuanceIsBoundedByTheBridge(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "bounded")) + + // Attempting to issue more than the bridge can back must fail. + if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil { + t.Fatal("issued more than the bridge can back") + } + + // And whatever has been issued must still fit in an int64, which is what + // makes every downstream balance arithmetic safe. + issued, err := l.TotalIssued(ctx) + if err != nil { + t.Fatalf("total issuance is no longer representable: %v", err) + } + if issued < 0 { + t.Fatalf("total issued is negative: %d", issued) + } +} + +// Postings that individually fit but collectively overflow the zero-sum check. +func TestOverflowingPostingSetIsRejected(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a")) + b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b")) + c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c")) + + // These sum to zero only if you ignore wraparound. + _, err := l.Post(ctx, "attack", nil, []ledger.Posting{ + {AccountID: a, AmountMsat: math.MaxInt64}, + {AccountID: b, AmountMsat: math.MaxInt64}, + {AccountID: c, AmountMsat: 2}, + }) + if err == nil { + t.Fatal("a posting set that overflows int64 was accepted") + } +} + +func TestSmallestPossibleAmount(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust")) + + if _, err := l.Deposit(ctx, p, 1); err != nil { + t.Fatalf("one millisatoshi rejected: %v", err) + } + if bal, _ := l.Balance(ctx, p); bal != 1 { + t.Fatalf("balance = %d, want 1", bal) + } + // Spending exactly the balance must leave zero, not fail. + q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust2")) + if _, err := l.Transfer(ctx, p, q, 1); err != nil { + t.Fatalf("spending the exact balance failed: %v", err) + } + if bal, _ := l.Balance(ctx, p); bal != 0 { + t.Fatalf("balance = %d after spending everything, want 0", bal) + } +} + +// Spending one millisatoshi more than you hold must fail, at every scale. +func TestOffByOneOverdraftAtEveryScale(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + + for _, amount := range []int64{1, 1000, 1_000_000, 100_000_000_000} { + p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scale"+itoa(amount))) + q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scaledst"+itoa(amount))) + if _, err := l.Deposit(ctx, p, amount); err != nil { + t.Fatal(err) + } + if _, err := l.Transfer(ctx, p, q, amount+1); err == nil { + t.Fatalf("overdraft by 1 accepted at scale %d", amount) + } + if _, err := l.Transfer(ctx, p, q, amount); err != nil { + t.Fatalf("exact-balance transfer rejected at scale %d: %v", amount, err) + } + } +} + +// Hammer one account from many goroutines and confirm not a single +// millisatoshi is created or lost. +func TestHighContentionConservesExactly(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + + hub, _ := l.EnsurePlayer(ctx, uniqueKey(t, "hub")) + if _, err := l.Deposit(ctx, hub, 1_000_000); err != nil { + t.Fatal(err) + } + + const workers = 16 + spokes := make([]int64, workers) + for i := range spokes { + spokes[i], _ = l.EnsurePlayer(ctx, uniqueKey(t, "spoke"+itoa(int64(i)))) + } + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 20; j++ { + // Push out and pull back; net zero if nothing is lost. + if _, err := l.Transfer(ctx, hub, spokes[i], 137); err == nil { + _, _ = l.Transfer(ctx, spokes[i], hub, 137) + } + } + }(i) + } + wg.Wait() + + total := int64(0) + for _, id := range spokes { + bal, err := l.Balance(ctx, id) + if err != nil { + t.Fatal(err) + } + total += bal + } + hubBal, err := l.Balance(ctx, hub) + if err != nil { + t.Fatal(err) + } + if total+hubBal != 1_000_000 { + t.Fatalf("value changed under contention: %d, want 1000000", total+hubBal) + } +} + +// Self-transfers must not mint money through double-counting the same account. +func TestSelfTransferDoesNotMint(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "self")) + if _, err := l.Deposit(ctx, p, 10_000); err != nil { + t.Fatal(err) + } + + _, _ = l.Transfer(ctx, p, p, 5_000) + + bal, err := l.Balance(ctx, p) + if err != nil { + t.Fatal(err) + } + if bal != 10_000 { + t.Fatalf("self-transfer changed balance to %d, want 10000", bal) + } +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [24]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index 75f826c..21ffa7c 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -13,6 +13,7 @@ import ( "context" "errors" "fmt" + "math/big" "sort" "time" @@ -57,12 +58,16 @@ func (l *Ledger) Post(ctx context.Context, kind string, roundID *int64, postings if len(postings) == 0 { return 0, ErrEmptyTransaction } - var sum int64 + // The zero-sum check must be overflow-safe. Accumulating into an int64 + // lets a crafted posting set wrap to zero — two legs of MaxInt64 and one + // of 2 sum to 0 in wrapping arithmetic — which would mint money out of + // nothing. big.Int has no such boundary. + sum := new(big.Int) for _, p := range postings { - sum += p.AmountMsat + sum.Add(sum, big.NewInt(p.AmountMsat)) } - if sum != 0 { - return 0, fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum) + if sum.Sign() != 0 { + return 0, fmt.Errorf("%w: sum is %s", ErrUnbalanced, sum.String()) } tx, err := l.pool.Begin(ctx) @@ -102,7 +107,14 @@ func (l *Ledger) Post(ctx context.Context, kind string, roundID *int64, postings return 0, fmt.Errorf("reading balance of account %d: %w", p.AccountID, err) } + // Detect wraparound before trusting the result: a credit that + // overflows would otherwise land as a negative balance, and a debit + // that underflows as a positive one. after := before + p.AmountMsat + if (p.AmountMsat > 0 && after < before) || (p.AmountMsat < 0 && after > before) { + return 0, fmt.Errorf("ledger: amount %d overflows the balance of account %d (%d)", + p.AmountMsat, p.AccountID, before) + } if after < 0 && !allowNegative { return 0, fmt.Errorf("%w: account %d holds %d, needs %d", ErrInsufficientFunds, p.AccountID, before, -p.AmountMsat) @@ -227,21 +239,37 @@ func (l *Ledger) AccountByName(ctx context.Context, name string) (int64, error) // every account except the external Lightning bridge. It changes only when // funds genuinely enter or leave, never through internal play. func (l *Ledger) TotalIssued(ctx context.Context) (int64, error) { - var total int64 - err := l.pool.QueryRow(ctx, - `SELECT COALESCE(SUM(b.balance_msat), 0) - FROM account_balances b - JOIN accounts a ON a.id = b.account_id - WHERE NOT a.allow_negative`).Scan(&total) - return total, err + return l.sumBalances(ctx, `WHERE NOT a.allow_negative`) } // ConservationCheck sums every account including the bridge. Because each // transaction sums to zero, this must always be exactly zero. A non-zero // result means the books are corrupt, and is the top-level audit alarm. func (l *Ledger) ConservationCheck(ctx context.Context) (int64, error) { - var total int64 - err := l.pool.QueryRow(ctx, - `SELECT COALESCE(SUM(balance_msat), 0) FROM account_balances`).Scan(&total) - return total, err + return l.sumBalances(ctx, ``) +} + +// sumBalances totals account balances. +// +// Postgres SUM() over bigint returns numeric, which can exceed int64 even +// though no single balance can. Scanning it as text and parsing through +// big.Int means a corrupt ledger reports a clear error instead of a scan +// failure — the alarm must survive the very condition it exists to detect. +func (l *Ledger) sumBalances(ctx context.Context, where string) (int64, error) { + var text string + err := l.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(b.balance_msat), 0)::text + FROM account_balances b + JOIN accounts a ON a.id = b.account_id `+where).Scan(&text) + if err != nil { + return 0, err + } + total, ok := new(big.Int).SetString(text, 10) + if !ok { + return 0, fmt.Errorf("ledger: unparseable balance total %q", text) + } + if !total.IsInt64() { + return 0, fmt.Errorf("ledger: balance total %s exceeds int64; the books are corrupt", text) + } + return total.Int64(), nil } diff --git a/pkg/ledger/property_test.go b/pkg/ledger/property_test.go index 938557b..fb7b335 100644 --- a/pkg/ledger/property_test.go +++ b/pkg/ledger/property_test.go @@ -16,10 +16,17 @@ func TestRandomActivityConservesValue(t *testing.T) { l := ledger.New(testPool(t)) ctx := context.Background() - house, err := l.AccountByName(ctx, "house_pot") + // The test uses its own counterparty rather than the shared house account, + // and sums only its own accounts. Asserting on a global total would fail + // whenever another package's tests run against the same database in + // parallel — a broken test, not a broken ledger. + house, err := l.EnsurePlayer(ctx, uniqueKey(t, "counterparty")) if err != nil { t.Fatal(err) } + if _, err := l.Deposit(ctx, house, 5_000_000); err != nil { + t.Fatal(err) + } const players = 8 ids := make([]int64, players) @@ -34,11 +41,22 @@ func TestRandomActivityConservesValue(t *testing.T) { } } - before, err := l.TotalIssued(ctx) - if err != nil { - t.Fatal(err) + // sumOwn totals only the accounts this test created. + sumOwn := func() int64 { + t.Helper() + var total int64 + for _, id := range append(append([]int64{}, ids...), house) { + bal, err := l.Balance(ctx, id) + if err != nil { + t.Fatal(err) + } + total += bal + } + return total } + before := sumOwn() + rng := rand.New(rand.NewSource(1)) roundID := int64(0) for i := 0; i < 400; i++ { @@ -75,11 +93,7 @@ func TestRandomActivityConservesValue(t *testing.T) { } } - after, err := l.TotalIssued(ctx) - if err != nil { - t.Fatal(err) - } - if before != after { + if after := sumOwn(); before != after { t.Fatalf("value not conserved: %d -> %d", before, after) } diff --git a/pkg/room/room_test.go b/pkg/room/room_test.go new file mode 100644 index 0000000..0fdf5cb --- /dev/null +++ b/pkg/room/room_test.go @@ -0,0 +1,631 @@ +package room + +import ( + "context" + "fmt" + "math/rand" + "os" + "sync" + "testing" + "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" +) + +// These are internal tests so the state machine can be driven a step at a time +// instead of waiting on wall-clock timers. + +var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63()) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("ARCADE_TEST_DSN") + if dsn == "" { + dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade" + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Skipf("no database available: %v", err) + } + if err := pool.Ping(context.Background()); err != nil { + t.Skipf("no database available: %v", err) + } + return pool +} + +type fixture struct { + t *testing.T + room *Room + ledger *ledger.Ledger + ctx context.Context +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + pool := testPool(t) + l := ledger.New(pool) + return &fixture{ + t: t, + room: New("rocket", pool, l), + ledger: l, + ctx: context.Background(), + } +} + +// player creates a funded account and returns its id and public key. +func (f *fixture) player(label string, fundMsat int64) (int64, []byte) { + f.t.Helper() + pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label)) + id, err := f.ledger.EnsurePlayer(f.ctx, pk) + if err != nil { + f.t.Fatal(err) + } + if fundMsat > 0 { + if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil { + f.t.Fatal(err) + } + } + return id, pk +} + +// openBetting drives the room into an open betting window. +func (f *fixture) openBetting() { + f.t.Helper() + if err := f.room.openRound(f.ctx); err != nil { + f.t.Fatal(err) + } +} + +// startRun locks the round and begins the climb. +func (f *fixture) startRun() { + f.t.Helper() + f.room.mu.Lock() + f.room.state = StateLocked + f.room.mu.Unlock() + if err := f.room.startRunning(f.ctx); err != nil { + f.t.Fatal(err) + } +} + +// advanceTo moves the round to a specific tick without settling. +func (f *fixture) advanceTo(tick int) { + f.t.Helper() + f.room.mu.Lock() + f.room.tick = tick + f.room.mu.Unlock() +} + +/* ---------------- lifecycle ---------------- */ + +func TestNewRoomStartsSettled(t *testing.T) { + f := newFixture(t) + if got := f.room.Snapshot().State; got != StateSettled { + t.Fatalf("new room state = %q, want %q", got, StateSettled) + } +} + +func TestOpenRoundCommitsBeforeBetting(t *testing.T) { + f := newFixture(t) + f.openBetting() + + snap := f.room.Snapshot() + if snap.State != StateBetting { + t.Fatalf("state = %q, want betting_open", snap.State) + } + if snap.Commitment == "" { + t.Fatal("no commitment published when betting opened") + } + // The seed must not leak while bets are still being taken. + if snap.ServerSeed != "" { + t.Fatal("server seed exposed during the betting window") + } + if snap.CrashPoint != "" { + t.Fatal("crash point exposed during the betting window") + } +} + +func TestSeedRevealedOnlyAfterSettlement(t *testing.T) { + f := newFixture(t) + f.openBetting() + f.startRun() + + if snap := f.room.Snapshot(); snap.ServerSeed != "" { + t.Fatal("seed revealed while the round was running") + } + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + snap := f.room.Snapshot() + if snap.ServerSeed == "" { + t.Fatal("seed not revealed after settlement") + } + if snap.CrashPoint == "" { + t.Fatal("crash point not revealed after settlement") + } +} + +func TestEachRoundGetsAFreshSeed(t *testing.T) { + f := newFixture(t) + seen := map[string]bool{} + for i := 0; i < 20; i++ { + f.openBetting() + c := f.room.Snapshot().Commitment + if seen[c] { + t.Fatalf("commitment reused on round %d", i) + } + seen[c] = true + } +} + +func TestNonceAdvancesPerRound(t *testing.T) { + f := newFixture(t) + f.openBetting() + first := f.room.nonce + f.openBetting() + if f.room.nonce != first+1 { + t.Fatalf("nonce went %d -> %d, want +1", first, f.room.nonce) + } +} + +/* ---------------- betting ---------------- */ + +func TestPlaceBetDebitsStakeImmediately(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + if before-after != 3_000 { + t.Fatalf("balance moved by %d, want 3000", before-after) + } +} + +func TestCannotBetOutsideBettingWindow(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + + // Room starts settled. + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + t.Fatal("bet accepted while settled") + } + + f.openBetting() + f.startRun() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + t.Fatal("bet accepted while running") + } +} + +func TestCannotBetTwiceInOneRound(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + t.Fatal(err) + } + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err == nil { + t.Fatal("second bet in the same round was accepted") + } +} + +func TestCannotBetMoreThanBalance(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 1_000) + f.openBetting() + + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000); err == nil { + t.Fatal("bet larger than balance was accepted") + } + // And nothing was taken. + if bal, _ := f.ledger.Balance(f.ctx, id); bal != 1_000 { + t.Fatalf("balance = %d after failed bet, want 1000", bal) + } +} + +func TestNonPositiveStakesRejected(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + + for _, stake := range []int64{0, -1, -5_000} { + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err == nil { + t.Fatalf("stake %d was accepted", stake) + } + } +} + +/* ---------------- cash out ---------------- */ + +func TestCashOutOnlyWhileRunning(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + t.Fatal(err) + } + + if _, err := f.room.CashOut(id); err == nil { + t.Fatal("cash out accepted during betting") + } + f.startRun() + if _, err := f.room.CashOut(id); err != nil { + t.Fatalf("cash out rejected while running: %v", err) + } +} + +func TestCannotCashOutTwice(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000) + f.startRun() + + if _, err := f.room.CashOut(id); err != nil { + t.Fatal(err) + } + if _, err := f.room.CashOut(id); err == nil { + t.Fatal("second cash out was accepted") + } +} + +func TestCannotCashOutWithoutABet(t *testing.T) { + f := newFixture(t) + id, _ := f.player("a", 10_000) + f.openBetting() + f.startRun() + + if _, err := f.room.CashOut(id); err == nil { + t.Fatal("cash out accepted with no bet placed") + } +} + +// Past the crash point there is nothing left to cash out. +func TestCannotCashOutAfterTheCrash(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 10_000) + f.openBetting() + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000) + f.startRun() + + // Jump past the crash point without letting the loop settle. + f.room.mu.Lock() + target := sim.TicksToMultiplier(f.room.crashPoint) + f.room.mu.Unlock() + f.advanceTo(target + 5) + + if _, err := f.room.CashOut(id); err == nil { + t.Fatal("cash out accepted after the crash point") + } +} + +/* ---------------- settlement ---------------- */ + +func TestCashedOutPlayerIsPaid(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + house, err := f.ledger.AccountByName(f.ctx, "house_pot") + if err != nil { + t.Fatal(err) + } + // Fund the house so it can cover the payout. + hp, _ := f.player("housefund", 1_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 1_000_000); err != nil { + t.Fatal(err) + } + + f.openBetting() + const stake = 10_000 + if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake); err != nil { + t.Fatal(err) + } + f.startRun() + + // Advance a little so the multiplier is meaningfully above 1.0, but stay + // below the crash point. + f.room.mu.Lock() + crashTick := sim.TicksToMultiplier(f.room.crashPoint) + f.room.mu.Unlock() + if crashTick < 2 { + t.Skip("crash point too low for this test; rerun") + } + f.advanceTo(crashTick - 1) + + at, err := f.room.CashOut(id) + if err != nil { + t.Fatal(err) + } + + beforeSettle, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + afterSettle, _ := f.ledger.Balance(f.ctx, id) + + want := stake * int64(at) / int64(fixed.One) + if afterSettle-beforeSettle != want { + t.Fatalf("payout = %d, want %d (cashed out at %v)", + afterSettle-beforeSettle, want, at) + } +} + +func TestPlayerWhoDidNotCashOutGetsNothing(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000); err != nil { + t.Fatal(err) + } + f.startRun() + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + if after != before { + t.Fatalf("balance changed by %d for a player who never cashed out", after-before) + } +} + +// The books must balance across a full round with mixed outcomes. +func TestBooksBalanceAcrossAFullRound(t *testing.T) { + f := newFixture(t) + house, _ := f.ledger.AccountByName(f.ctx, "house_pot") + hp, _ := f.player("housefund", 10_000_000) + if _, err := f.ledger.Transfer(f.ctx, hp, house, 10_000_000); err != nil { + t.Fatal(err) + } + + f.openBetting() + var ids []int64 + for i := 0; i < 5; i++ { + id, pk := f.player(fmt.Sprintf("p%d", i), 100_000) + if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + f.startRun() + + f.room.mu.Lock() + crashTick := sim.TicksToMultiplier(f.room.crashPoint) + f.room.mu.Unlock() + if crashTick > 2 { + f.advanceTo(crashTick - 1) + // Half cash out, half ride it in. + for i, id := range ids { + if i%2 == 0 { + if _, err := f.room.CashOut(id); err != nil { + t.Fatalf("cash out %d: %v", i, err) + } + } + } + } + + if err := f.room.settle(f.ctx); err != nil { + t.Fatal(err) + } + total, err := f.ledger.ConservationCheck(f.ctx) + if err != nil { + t.Fatal(err) + } + if total != 0 { + t.Fatalf("books do not balance after settlement: %d", total) + } +} + +/* ---------------- fairness wiring ---------------- */ + +// The crash point must follow from the committed seed and the participant set, +// which is what makes the published proof meaningful. +func TestCrashPointDerivesFromCommittedSeedAndPlayers(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + + f.openBetting() + if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); err != nil { + t.Fatal(err) + } + f.startRun() + + f.room.mu.RLock() + seed := f.room.serverSeed + nonce := f.room.nonce + order := append([][]byte(nil), f.room.order...) + actual := f.room.crashPoint + f.room.mu.RUnlock() + + expected := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed(order), nonce)) + if actual != expected { + t.Fatalf("crash point %v does not follow from the published inputs (want %v)", + actual, expected) + } +} + +func TestAddingAPlayerChangesTheOutcome(t *testing.T) { + f := newFixture(t) + _, pkA := f.player("a", 100_000) + _, pkB := f.player("b", 100_000) + + seed := fair.NewServerSeed() + one := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed([][]byte{pkA}), 1)) + two := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed([][]byte{pkA, pkB}), 1)) + if one == two { + t.Fatal("a second participant did not affect the outcome") + } +} + +/* ---------------- broadcast ---------------- */ + +func TestSubscriberReceivesUpdates(t *testing.T) { + f := newFixture(t) + ch, unsubscribe := f.room.Subscribe() + defer unsubscribe() + + f.openBetting() + + select { + case snap := <-ch: + if snap.State != StateBetting { + t.Fatalf("received state %q, want betting_open", snap.State) + } + case <-time.After(2 * time.Second): + t.Fatal("subscriber received no update") + } +} + +// A phone that stops reading must not stall the round for everyone else. +func TestSlowSubscriberDoesNotBlockTheRoom(t *testing.T) { + f := newFixture(t) + _, unsubscribe := f.room.Subscribe() // never drained + defer unsubscribe() + + done := make(chan struct{}) + go func() { + for i := 0; i < 200; i++ { + f.room.broadcast() + } + close(done) + }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("broadcast blocked on a subscriber that stopped reading") + } +} + +func TestUnsubscribeStopsDelivery(t *testing.T) { + f := newFixture(t) + ch, unsubscribe := f.room.Subscribe() + unsubscribe() + + // The channel is closed, so a receive returns immediately with ok == false. + select { + case _, ok := <-ch: + if ok { + t.Fatal("received a value after unsubscribing") + } + case <-time.After(time.Second): + t.Fatal("channel was not closed by unsubscribe") + } +} + +/* ---------------- concurrency ---------------- */ + +// Many players betting at once must all be recorded, with no lost updates and +// no double-charging. +func TestConcurrentBetsAreAllRecorded(t *testing.T) { + f := newFixture(t) + f.openBetting() + + const players = 12 + type acct struct { + id int64 + pk []byte + } + accts := make([]acct, players) + for i := range accts { + id, pk := f.player(fmt.Sprintf("c%d", i), 100_000) + accts[i] = acct{id, pk} + } + + var wg sync.WaitGroup + errs := make([]error, players) + for i, a := range accts { + wg.Add(1) + go func(i int, a acct) { + defer wg.Done() + errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000) + }(i, a) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("player %d could not bet: %v", i, err) + } + } + if got := len(f.room.Snapshot().Players); got != players { + t.Fatalf("%d players in the round, want %d", got, players) + } + for _, a := range accts { + bal, _ := f.ledger.Balance(f.ctx, a.id) + if bal != 95_000 { + t.Fatalf("account %d balance = %d, want 95000", a.id, bal) + } + } +} + +// Concurrent cash-outs by the same player must yield exactly one success. +func TestConcurrentCashOutsYieldOne(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + _ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000) + f.startRun() + + const attempts = 10 + var wg sync.WaitGroup + results := make([]error, attempts) + for i := 0; i < attempts; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, results[i] = f.room.CashOut(id) + }(i) + } + wg.Wait() + + successes := 0 + for _, err := range results { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("%d concurrent cash-outs succeeded, want 1", successes) + } +} + +/* ---------------- snapshot ---------------- */ + +func TestSnapshotReportsCashOutMultiplier(t *testing.T) { + f := newFixture(t) + id, pk := f.player("a", 100_000) + f.openBetting() + _ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000) + f.startRun() + + if _, err := f.room.CashOut(id); err != nil { + t.Fatal(err) + } + snap := f.room.Snapshot() + if len(snap.Players) != 1 { + t.Fatalf("%d players in snapshot, want 1", len(snap.Players)) + } + if snap.Players[0].CashedOut == "" { + t.Fatal("snapshot does not show the cash-out") + } + if snap.Players[0].Nickname != "nick" { + t.Fatalf("nickname = %q, want %q", snap.Players[0].Nickname, "nick") + } +} + +func TestMultiplierStartsAtOneEachRound(t *testing.T) { + f := newFixture(t) + f.openBetting() + if got := f.room.Snapshot().Multiplier; got != fixed.One.String() { + t.Fatalf("multiplier at round open = %s, want %s", got, fixed.One.String()) + } +}