package game31 // The 12 game27/game28 exploit PoCs (SECURITY_GAME28.md), ported to game29. // Convention here is the reverse of the audit copy: a test PASSES when the // exploit is BLOCKED. import ( "chain" "strconv" "strings" "testing" ) const forgedSig = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" // H1a: forged dust bank with zero clicks + free Gnome Pass. func TestH1a_ForgeDustBankWithFreePass(cur realm, t *testing.T) { setup() bot := ta("h1a-bot") testing.SetRealm(testing.NewUserRealm(bot)) mustAbort(t, "free pass", revive(func() { ActivateGnomePass(cross(cur), 3650) }), "") mustAbort(t, "free pass 30d", revive(func() { ActivateGnomePass(cross(cur), 30) }), "attach exactly") if HasActivePass(bot.String()) { t.Errorf("pass still free") } // No server voucher → no claim, whatever the signature. mustAbort(t, "unvouchered claim", revive(func() { ClaimAndMint(cross(cur), `[["grin","1440"]]`, 1, 1234567890+600, forgedSig) }), "invalid voucher signature") if bal(bot, "grin") != 0 { t.Errorf("bot forged %d grin", bal(bot, "grin")) } } // H1b: dust refilling forever for a bot. A voucher is single use, so the bot // can't reuse one after the bucket refills. func TestH1b_DustRefillsForever(cur realm, t *testing.T) { setup() bot := ta("h1b-bot") payload := `[["grin","100"]]` n, e, s := voucher(t, "claim", bot, payload) testing.SetRealm(testing.NewUserRealm(bot)) ClaimAndMint(cross(cur), payload, n, e, s) // one legit, vouchered claim testing.SkipHeights(720) // 1 h later the bucket is full again… mustAbort(t, "replay after refill", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "") if bal(bot, "grin") != 100 { t.Errorf("grin=%d, want 100", bal(bot, "grin")) } } // H1c: made-up coin ids, even with a valid voucher. func TestH1c_FakeCoinId(cur realm, t *testing.T) { setup() bot := ta("h1c-bot") payload := `[["fake-coin","50000"]]` n, e, s := voucher(t, "claim", bot, payload) testing.SetRealm(testing.NewUserRealm(bot)) mustAbort(t, "fake coin", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "unknown coin id") } // H1d: Fist mining mythic coins, even with a valid voucher. func TestH1d_FistForgesMythic(cur realm, t *testing.T) { setup() bot := ta("h1d-bot") payload := `[["dragon","1"]]` n, e, s := voucher(t, "claim", bot, payload) testing.SetRealm(testing.NewUserRealm(bot)) mustAbort(t, "fist→mythic", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "cannot mine rarity") if bal(bot, "dragon") != 0 { t.Errorf("dragon minted") } } // H1e: zero-click tool climb. Without vouchers a script claims nothing, and a // fresh bucket only holds 5 min of allowance (game28: a full hour). func TestH1e_ZeroClickToolClimb(cur realm, t *testing.T) { setup() bot := ta("h1e-bot") testing.SetRealm(testing.NewUserRealm(bot)) for i := 0; i < 5; i++ { _ = revive(func() { ClaimAndMint(cross(cur), `[["grin","24"]]`, int64(i+1), 1234567890+600, forgedSig) }) testing.SkipHeights(13) } if bal(bot, "grin") != 0 { t.Errorf("zero-click bot got %d grin", bal(bot, "grin")) } mustAbort(t, "buy starter-twig", revive(func() { BuyTool(cross(cur), "starter-twig") }), "insufficient") fresh := ta("h1e-fresh") if a := allowanceFor(fresh, 0); a != 210 { t.Errorf("fresh dust allowance = %d, want 210 (5 min @ 42/min, year-plan dust headroom 1.75)", a) } // Even a vouchered claim can't exceed the 5-min grace bank. payload := `[["grin","1440"]]` n, e, s := voucher(t, "claim", fresh, payload) testing.SetRealm(testing.NewUserRealm(fresh)) mustAbort(t, "1h bank on fresh wallet", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "rate limit") if bal(fresh, "grin") != 0 { t.Errorf("fresh wallet got %d", bal(fresh, "grin")) } } // H1f: sybil funnel. Sybils can't mint without vouchers, so there's nothing // to funnel; a voucher for one wallet doesn't work for another. func TestH1f_SybilFunnel(cur realm, t *testing.T) { setup() main := ta("h1f-main") payload := `[["grin","100"]]` n, e, s := voucher(t, "claim", main, payload) // a real voucher for main for i := 0; i < 10; i++ { sy := ta("h1f-sybil-" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(sy)) _ = revive(func() { ActivateGnomePass(cross(cur), 3650) }) _ = revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }) // stolen voucher _ = revive(func() { Transfer(cross(cur), "grin", main.String(), "100") }) if bal(sy, "grin") != 0 { t.Errorf("sybil %d minted", i) } } if bal(main, "grin") != 0 { t.Errorf("main received %d grin from sybils", bal(main, "grin")) } } // H2a: PvP reveal never checked against the commit. func TestH2a_PvpRevealIgnoresCommit(cur realm, t *testing.T) { setup() alice, bob := ta("h2a-alice"), ta("h2a-bob") give(alice, "grin", 1000) give(bob, "grin", 1000) testing.SetRealm(testing.NewUserRealm(alice)) lobby := CreatePvpLobby(cross(cur), "rps", "grin", "0") testing.SetRealm(testing.NewUserRealm(bob)) JoinPvpLobby(cross(cur), lobby) testing.SetRealm(testing.NewUserRealm(alice)) CommitPvpMove(cross(cur), lobby, h("rock", "a-salt")) testing.SetRealm(testing.NewUserRealm(bob)) mustAbort(t, "non-hash commit", revive(func() { CommitPvpMove(cross(cur), lobby, "garbage") }), "64 hex") CommitPvpMove(cross(cur), lobby, h("scissors", "b-salt")) testing.SetRealm(testing.NewUserRealm(alice)) RevealPvpMove(cross(cur), lobby, "rock", "a-salt") testing.SetRealm(testing.NewUserRealm(bob)) mustAbort(t, "reveal different move", revive(func() { RevealPvpMove(cross(cur), lobby, "paper", "b-salt") }), "does not match") if strings.Contains(PvpLobby(lobby), `"status":"finished"`) { t.Errorf("a cheat reveal settled the lobby") } } // H2b: commits overwritable by anyone, any time, on any lobby id. func TestH2b_CommitOverwrite(cur realm, t *testing.T) { setup() eve := ta("h2b-eve") testing.SetRealm(testing.NewUserRealm(eve)) mustAbort(t, "commit on missing lobby", revive(func() { CommitPvpMove(cross(cur), "pvp-nonexistent-1", h("rock", "x")) }), "not in progress") a, b := ta("h2b-a"), ta("h2b-b") testing.SetRealm(testing.NewUserRealm(a)) lobby := CreatePvpLobby(cross(cur), "rps", "grin", "0") testing.SetRealm(testing.NewUserRealm(b)) JoinPvpLobby(cross(cur), lobby) testing.SetRealm(testing.NewUserRealm(eve)) mustAbort(t, "outsider commit", revive(func() { CommitPvpMove(cross(cur), lobby, h("rock", "x")) }), "not a lobby participant") testing.SetRealm(testing.NewUserRealm(a)) CommitPvpMove(cross(cur), lobby, h("rock", "1")) mustAbort(t, "overwrite", revive(func() { CommitPvpMove(cross(cur), lobby, h("paper", "2")) }), "already committed") if pvpLobbies.Get(lobby+":commit:"+a.String()).(string) != h("rock", "1") { t.Errorf("commit overwritten") } } // H2e: two lobbies in one block got the same id (escrow wiped). func TestH2e_LobbyIdCollision(cur realm, t *testing.T) { setup() a, c := ta("coll-alice"), ta("coll-carol") give(a, "grin", 100) give(c, "grin", 100) testing.SetRealm(testing.NewUserRealm(a)) id1 := CreatePvpLobby(cross(cur), "rps", "grin", "0") testing.SetRealm(testing.NewUserRealm(c)) id2 := CreatePvpLobby(cross(cur), "rps", "grin", "0") if id1 == id2 { t.Errorf("lobby id collision: %s", id1) } if !strings.Contains(PvpLobby(id1), a.String()) { t.Errorf("alice's lobby record lost") } } // NEW (game28): any medal id claimable, tasks completable by anyone. func TestNew_AnyMedalClaimable(cur realm, t *testing.T) { setup() p := ta("medal-farmer") payload := `["mythic-legend","made-up-medal"]` testing.SetRealm(testing.NewUserRealm(p)) mustAbort(t, "unvouchered medals", revive(func() { BatchClaimAchievements(cross(cur), payload, 1, 1234567890+600, forgedSig) }), "invalid voucher") n, e, s := voucher(t, "ach", p, payload) testing.SetRealm(testing.NewUserRealm(p)) got := BatchClaimAchievements(cross(cur), payload, n, e, s) // even with a voucher mustAbort(t, "CompleteTask", revive(func() { CompleteTask(cross(cur), "any-task") }), "operator-only") mustAbort(t, "CreateTask", revive(func() { CreateTask(cross(cur), "any-task", "x") }), "operator only") m := GetMedals(p.String()) if got != 0 || strings.Contains(m, "made-up-medal") || strings.Contains(m, "mythic-legend") { t.Errorf("non-allowlisted medal claimed: %s", m) } } var _ = chain.NewCoin // H2c: no casino refund once the server seed is revealed (two-sided commit/reveal). func TestH2c_CasinoRefundAfterReveal(cur realm, t *testing.T) { adm := setup() seedCasino("joy", 1000000) p := ta("h2c-gambler") give(p, "joy", 100) testing.SetRealm(testing.NewUserRealm(adm)) CommitCasinoSeed(cross(cur), "round-1", p.String(), h0("serverseed")) testing.SetRealm(testing.NewUserRealm(p)) PlaceCasinoBet(cross(cur), "round-1", "coin_flip", "joy", "100", "heads", h0("cs")) testing.SetRealm(testing.NewUserRealm(adm)) RevealCasinoSeed(cross(cur), "round-1", "serverseed") after := bal(p, "joy") testing.SkipHeights(2000) testing.SetRealm(testing.NewUserRealm(p)) mustAbort(t, "refund after reveal", revive(func() { RefundCasinoBet(cross(cur), "round-1") }), "no refunds") if bal(p, "joy") != after { t.Errorf("refund after reveal paid out") } } // H2d: one bet per round, bound to its player; no bet after the reveal. func TestH2d_BetAfterRevealAndOverwrite(cur realm, t *testing.T) { adm := setup() seedCasino("joy", 1000000) p, q := ta("h2d-gambler"), ta("h2d-other") give(p, "joy", 100) give(q, "joy", 100) testing.SetRealm(testing.NewUserRealm(adm)) CommitCasinoSeed(cross(cur), "round-2", p.String(), h0("s2")) testing.SetRealm(testing.NewUserRealm(q)) mustAbort(t, "other player's round", revive(func() { PlaceCasinoBet(cross(cur), "round-2", "coin_flip", "joy", "10", "heads", h0("x")) }), "another player") testing.SetRealm(testing.NewUserRealm(p)) PlaceCasinoBet(cross(cur), "round-2", "coin_flip", "joy", "40", "heads", h0("cs")) mustAbort(t, "second bet", revive(func() { PlaceCasinoBet(cross(cur), "round-2", "coin_flip", "joy", "60", "tails", h0("cs")) }), "") testing.SetRealm(testing.NewUserRealm(adm)) RevealCasinoSeed(cross(cur), "round-2", "s2") testing.SetRealm(testing.NewUserRealm(p)) mustAbort(t, "bet after reveal", revive(func() { PlaceCasinoBet(cross(cur), "round-2", "coin_flip", "joy", "10", "heads", h0("cs")) }), "") b := casinoBets.Get("round-2:" + p.String()).(*casinoBet) if b.amount != 40 { t.Errorf("first bet overwritten: %d", b.amount) } }