feat: refund abandoned rounds; full-journey and capacity tests

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>
This commit is contained in:
drjones
2026-08-05 23:18:29 +00:00
parent c12640cf52
commit 3bdb518f9c
8 changed files with 829 additions and 5 deletions

View File

@@ -342,3 +342,235 @@ func TestAutoCashOutThroughTheAPI(t *testing.T) {
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)
}
}

View File

@@ -134,6 +134,11 @@ func main() {
go h.supervise(ctx)
}
// Sweep for rounds abandoned by an instance that died mid-flight and
// refund their stakes. Every instance runs this; the claim is atomic, so
// concurrent sweeps refund exactly once.
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
srv := &http.Server{
Addr: addr,
Handler: s.routes(),