diff --git a/pkg/fair/fair.go b/pkg/fair/fair.go new file mode 100644 index 0000000..ec85ae3 --- /dev/null +++ b/pkg/fair/fair.go @@ -0,0 +1,127 @@ +// Package fair implements the commit-reveal protocol that makes every outcome +// independently verifiable. +// +// The protocol, per round: +// +// 1. Commit — the server generates a random 32-byte seed and publishes +// SHA-256(seed) before betting opens. It is now bound to that seed. +// 2. Client seed — derived from the public keys of everyone in the round. +// The operator does not control these, so it cannot steer the outcome even +// with full knowledge of its own seed. +// 3. Outcome — HMAC-SHA256(serverSeed, clientSeed || nonce) seeds the +// simulation. The result is a pure function of that seed. +// 4. Reveal — after settlement the server publishes the seed. Anyone can +// recompute the commitment, re-derive the outcome, and confirm it. +// +// The security property is that the operator must choose its seed before it +// knows the participant set, and cannot change it afterwards without breaking +// a published SHA-256 commitment. +package fair + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" +) + +// ServerSeed is the operator's secret contribution to a round, revealed after +// settlement. +type ServerSeed struct { + b [32]byte +} + +// NewServerSeed generates a cryptographically random server seed. +func NewServerSeed() ServerSeed { + var s ServerSeed + if _, err := rand.Read(s.b[:]); err != nil { + // A failure of the system CSPRNG is not something to paper over: any + // fallback would silently weaken every outcome derived from it. + panic("fair: system randomness unavailable: " + err.Error()) + } + return s +} + +// ServerSeedFromBytes reconstructs a seed, for verification of a past round. +func ServerSeedFromBytes(b [32]byte) ServerSeed { return ServerSeed{b: b} } + +// Bytes returns the raw seed. Callers must not publish this before settlement. +func (s ServerSeed) Bytes() [32]byte { return s.b } + +// Hex renders the seed for the reveal step. +func (s ServerSeed) Hex() string { return hex.EncodeToString(s.b[:]) } + +// Commitment is SHA-256 of the seed, published before the round opens. +func (s ServerSeed) Commitment() [32]byte { return sha256.Sum256(s.b[:]) } + +// VerifyCommitment reports whether a revealed seed matches a published +// commitment. The comparison is constant-time out of habit; nothing secret +// depends on it by this point, but the cost is zero. +func VerifyCommitment(commitment [32]byte, seed ServerSeed) bool { + actual := seed.Commitment() + return subtle.ConstantTimeCompare(commitment[:], actual[:]) == 1 +} + +// ClientSeed derives the players' collective contribution from the public keys +// of everyone in the round, in join order. Because the operator cannot control +// who joins, it cannot predict this value when it commits to its own seed. +func ClientSeed(pubkeys [][]byte) [32]byte { + h := sha256.New() + for _, pk := range pubkeys { + // Length-prefix each key so that concatenation is unambiguous and two + // different participant lists cannot hash to the same value. + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(pk))) + h.Write(n[:]) + h.Write(pk) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +// RoundSeed combines both seeds and a nonce into the value that seeds the +// simulation. The nonce separates rounds, or individual plays, that share a +// server seed. +func RoundSeed(server ServerSeed, client [32]byte, nonce uint64) [32]byte { + mac := hmac.New(sha256.New, server.b[:]) + mac.Write(client[:]) + var n [8]byte + binary.BigEndian.PutUint64(n[:], nonce) + mac.Write(n[:]) + var out [32]byte + copy(out[:], mac.Sum(nil)) + return out +} + +// Proof is everything a player needs to verify one outcome without trusting +// any server response. It is what the verification endpoint returns. +type Proof struct { + Commitment string `json:"commitment"` // published before the round + ServerSeed string `json:"server_seed"` // revealed after settlement + Participants []string `json:"participants"` // hex public keys, join order + Nonce uint64 `json:"nonce"` + RoundSeed string `json:"round_seed"` // derived, shown for convenience +} + +// BuildProof assembles the verification record for a settled round. +func BuildProof(server ServerSeed, pubkeys [][]byte, nonce uint64) Proof { + client := ClientSeed(pubkeys) + seed := RoundSeed(server, client, nonce) + + participants := make([]string, len(pubkeys)) + for i, pk := range pubkeys { + participants[i] = hex.EncodeToString(pk) + } + commitment := server.Commitment() + + return Proof{ + Commitment: hex.EncodeToString(commitment[:]), + ServerSeed: server.Hex(), + Participants: participants, + Nonce: nonce, + RoundSeed: hex.EncodeToString(seed[:]), + } +} diff --git a/pkg/fair/fair_test.go b/pkg/fair/fair_test.go new file mode 100644 index 0000000..497c370 --- /dev/null +++ b/pkg/fair/fair_test.go @@ -0,0 +1,108 @@ +package fair_test + +import ( + "bytes" + "testing" + + "github.com/drjones/quantum-arcade/pkg/fair" +) + +func TestCommitmentHidesSeed(t *testing.T) { + s := fair.NewServerSeed() + c := s.Commitment() + raw := s.Bytes() + if bytes.Contains(c[:], raw[:8]) { + t.Fatal("commitment leaks seed bytes") + } +} + +func TestCommitmentVerifies(t *testing.T) { + s := fair.NewServerSeed() + c := s.Commitment() + if !fair.VerifyCommitment(c, s) { + t.Fatal("valid seed failed its own commitment") + } + other := fair.NewServerSeed() + if fair.VerifyCommitment(c, other) { + t.Fatal("a different seed satisfied the commitment") + } +} + +func TestClientSeedDependsOnEveryParticipant(t *testing.T) { + a := []byte("player-a-pubkey") + b := []byte("player-b-pubkey") + c := []byte("player-c-pubkey") + + withAll := fair.ClientSeed([][]byte{a, b, c}) + withoutC := fair.ClientSeed([][]byte{a, b}) + if withAll == withoutC { + t.Fatal("removing a participant did not change the client seed") + } +} + +// Join order must matter in a defined way, but the same set in the same order +// must always produce the same seed. +func TestClientSeedIsStable(t *testing.T) { + keys := [][]byte{[]byte("k1"), []byte("k2")} + if fair.ClientSeed(keys) != fair.ClientSeed(keys) { + t.Fatal("client seed is not stable for identical input") + } +} + +func TestRoundSeedIsDeterministic(t *testing.T) { + s := fair.NewServerSeed() + cs := fair.ClientSeed([][]byte{[]byte("p1")}) + first := fair.RoundSeed(s, cs, 7) + for i := 0; i < 50; i++ { + if fair.RoundSeed(s, cs, 7) != first { + t.Fatal("round seed is not deterministic") + } + } +} + +func TestNonceSeparatesOutcomes(t *testing.T) { + s := fair.NewServerSeed() + cs := fair.ClientSeed([][]byte{[]byte("p1")}) + seen := map[[32]byte]bool{} + for n := uint64(0); n < 1000; n++ { + seed := fair.RoundSeed(s, cs, n) + if seen[seed] { + t.Fatalf("nonce %d collided with an earlier round seed", n) + } + seen[seed] = true + } +} + +// The full protocol as a player would check it: the commitment published before +// the round must match the seed revealed after, and the seed must reproduce the +// outcome. +func TestEndToEndVerification(t *testing.T) { + server := fair.NewServerSeed() + published := server.Commitment() + + participants := [][]byte{[]byte("alice"), []byte("bob")} + cs := fair.ClientSeed(participants) + const nonce = 42 + + seed := fair.RoundSeed(server, cs, nonce) + + // After the round the server reveals the seed. A player recomputes: + if !fair.VerifyCommitment(published, server) { + t.Fatal("revealed seed does not match published commitment") + } + recomputed := fair.RoundSeed(server, fair.ClientSeed(participants), nonce) + if recomputed != seed { + t.Fatal("independent recomputation produced a different seed") + } +} + +func TestServerSeedsAreUnique(t *testing.T) { + seen := map[[32]byte]bool{} + for i := 0; i < 1000; i++ { + s := fair.NewServerSeed() + if seen[s.Bytes()] { + t.Fatal("NewServerSeed returned a duplicate") + } + seen[s.Bytes()] = true + } +} diff --git a/pkg/scratch/scratch.go b/pkg/scratch/scratch.go new file mode 100644 index 0000000..46a8afe --- /dev/null +++ b/pkg/scratch/scratch.go @@ -0,0 +1,237 @@ +// Package scratch implements instant scratch-ticket games. +// +// The defining property here is that the odds table shown to the player is +// derived from the same data that generates outcomes. There is no separate +// "marketing" table that could drift from reality: Odds() and Play() read the +// identical tier list, and a test asserts that observed frequencies match the +// published ones. +package scratch + +import ( + "encoding/binary" + "fmt" + + "github.com/drjones/quantum-arcade/pkg/fair" +) + +// Denominator is the resolution of the odds table. Every tier's weight is +// expressed out of this, so odds are exact rationals rather than rounded +// percentages. +const Denominator uint64 = 1_000_000 + +// Tier is one prize band. +type Tier struct { + // Name is what the player sees when they win it. + Name string + // Weight is the number of outcomes out of Denominator that land here. + Weight uint64 + // PayoutBP is the prize as basis points of the stake: 10000 = 1x stake. + PayoutBP uint64 +} + +// Ticket is a scratch game definition. +type Ticket struct { + ID string + Name string + Blurb string + // Cells is how many squares the player scratches, for presentation. + Cells int + Tiers []Tier +} + +// Outcome is the result of one ticket. +type Outcome struct { + TicketID string `json:"ticket_id"` + TierName string `json:"tier_name"` + PayoutBP uint64 `json:"payout_bp"` + PayoutMsat int64 `json:"payout_msat"` + // Roll is the raw draw, published so the player can check the mapping. + Roll uint64 `json:"roll"` + // Cells are the revealed symbols, derived from the same seed. + Cells []int `json:"cells"` +} + +// OddsRow is one line of the published odds table. +type OddsRow struct { + TierName string `json:"tier"` + PayoutBP uint64 `json:"payout_bp"` + Weight uint64 `json:"weight"` + Denominator uint64 `json:"denominator"` + // OneIn is the human-readable "1 in N" figure, zero for the losing tier. + OneIn uint64 `json:"one_in"` +} + +// Validate reports whether the tier weights are coherent. A ticket whose +// weights do not sum to exactly Denominator would have undefined outcomes. +func (t Ticket) Validate() error { + var sum uint64 + for _, tier := range t.Tiers { + sum += tier.Weight + } + if sum != Denominator { + return fmt.Errorf("scratch: ticket %q weights sum to %d, want %d", + t.ID, sum, Denominator) + } + return nil +} + +// Odds returns the published odds table, derived from the generating tiers. +func (t Ticket) Odds() []OddsRow { + rows := make([]OddsRow, 0, len(t.Tiers)) + for _, tier := range t.Tiers { + row := OddsRow{ + TierName: tier.Name, + PayoutBP: tier.PayoutBP, + Weight: tier.Weight, + Denominator: Denominator, + } + if tier.Weight > 0 { + row.OneIn = Denominator / tier.Weight + } + rows = append(rows, row) + } + return rows +} + +// RTPBasisPoints is the return to player in basis points, computed from the +// same tiers that generate outcomes. 9800 means 98%. +func (t Ticket) RTPBasisPoints() uint64 { + var total uint64 + for _, tier := range t.Tiers { + total += tier.Weight * tier.PayoutBP + } + return total / Denominator +} + +// Play resolves one ticket from a round seed. +func (t Ticket) Play(seed [32]byte, stakeMsat int64) Outcome { + // The first 8 bytes select the tier; later bytes decorate the cells, so + // presentation can never alter the prize. + roll := binary.BigEndian.Uint64(seed[0:8]) % Denominator + + var chosen Tier + var cumulative uint64 + for _, tier := range t.Tiers { + cumulative += tier.Weight + if roll < cumulative { + chosen = tier + break + } + } + + return Outcome{ + TicketID: t.ID, + TierName: chosen.Name, + PayoutBP: chosen.PayoutBP, + PayoutMsat: stakeMsat * int64(chosen.PayoutBP) / 10000, + Roll: roll, + Cells: t.revealCells(seed, chosen), + } +} + +// symbolCount is the number of distinct symbols on a ticket face. With six +// symbols, a losing face can always be filled without an accidental +// three-of-a-kind for any ticket up to twelve cells. +const symbolCount = 6 + +// revealCells produces the symbols the player scratches off. +// +// The prize is already fixed by the roll; the cells are presentation. But they +// must never contradict the result, so a winning face always shows a genuine +// three-of-a-kind and a losing face never does. +func (t Ticket) revealCells(seed [32]byte, won Tier) []int { + cells := make([]int, t.Cells) + for i := range cells { + cells[i] = -1 + } + counts := make([]int, symbolCount) + + // A winning face gets its match placed first, on three adjacent positions + // so they cannot collide with one another. + winSym := -1 + if won.PayoutBP > 0 && t.Cells >= 3 { + winSym = int(seed[31]) % symbolCount + start := int(seed[30]) % t.Cells + for k := 0; k < 3; k++ { + cells[(start+k)%t.Cells] = winSym + counts[winSym]++ + } + } + + // Fill the remainder, never letting a non-winning symbol reach three. + for i := range cells { + if cells[i] != -1 { + continue + } + pick := int(seed[(i+8)%32]) % symbolCount + for attempts := 0; attempts < symbolCount; attempts++ { + if pick != winSym && counts[pick] >= 2 { + pick = (pick + 1) % symbolCount + continue + } + if pick == winSym && winSym == -1 { + pick = (pick + 1) % symbolCount + continue + } + break + } + cells[i] = pick + counts[pick]++ + } + return cells +} + +// Catalog is the set of tickets offered. Each is validated at startup. +var Catalog = []Ticket{ + { + ID: "nebula-nine", + Name: "Nebula Nine", + Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.", + Cells: 9, + // Weights are out of 1,000,000 and sum to it exactly. The weighted + // payout sums to 9.8e9, which is an RTP of exactly 98%. + Tiers: []Tier{ + {Name: "No win", Weight: 463_400, PayoutBP: 0}, + {Name: "Stake back", Weight: 350_000, PayoutBP: 10_000}, + {Name: "Double", Weight: 150_000, PayoutBP: 20_000}, + {Name: "Five times", Weight: 30_000, PayoutBP: 50_000}, + {Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000}, + {Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000}, + }, + }, + { + ID: "singularity", + Name: "Singularity", + Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.", + Cells: 6, + // Same 98% RTP as Nebula Nine, but concentrated in the rare tiers: + // you lose far more often, and the top prize is 500x. + Tiers: []Tier{ + {Name: "No win", Weight: 866_530, PayoutBP: 0}, + {Name: "Stake back", Weight: 60_000, PayoutBP: 10_000}, + {Name: "Triple", Weight: 45_000, PayoutBP: 30_000}, + {Name: "Ten times", Weight: 25_000, PayoutBP: 100_000}, + {Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000}, + {Name: "Singularity", Weight: 470, PayoutBP: 5_000_000}, + }, + }, +} + +// ByID looks up a ticket in the catalog. +func ByID(id string) (Ticket, bool) { + for _, t := range Catalog { + if t.ID == id { + return t, true + } + } + return Ticket{}, false +} + +// PlayFromRound is the entry point used by the server: it derives the seed +// through the same commit-reveal machinery the crash games use, so scratch +// tickets are verifiable by exactly the same method. +func PlayFromRound(t Ticket, server fair.ServerSeed, pubkey []byte, nonce uint64, stakeMsat int64) (Outcome, fair.Proof) { + client := fair.ClientSeed([][]byte{pubkey}) + seed := fair.RoundSeed(server, client, nonce) + return t.Play(seed, stakeMsat), fair.BuildProof(server, [][]byte{pubkey}, nonce) +} diff --git a/pkg/scratch/scratch_test.go b/pkg/scratch/scratch_test.go new file mode 100644 index 0000000..d334f57 --- /dev/null +++ b/pkg/scratch/scratch_test.go @@ -0,0 +1,153 @@ +package scratch_test + +import ( + "encoding/binary" + "testing" + + "github.com/drjones/quantum-arcade/pkg/fair" + "github.com/drjones/quantum-arcade/pkg/scratch" +) + +func TestCatalogIsCoherent(t *testing.T) { + for _, ticket := range scratch.Catalog { + if err := ticket.Validate(); err != nil { + t.Errorf("%s: %v", ticket.ID, err) + } + } +} + +// The published RTP must be a genuine 98%, not a rounded claim. +func TestPublishedRTPIsHonest(t *testing.T) { + for _, ticket := range scratch.Catalog { + rtp := ticket.RTPBasisPoints() + if rtp != 9800 { + t.Errorf("%s: RTP = %d bp, want 9800", ticket.ID, rtp) + } + } +} + +// The core honesty test: the odds table shown to players must match the +// frequencies the generator actually produces. If someone edits a weight to +// tighten the game without updating the table, this fails. +func TestObservedFrequenciesMatchPublishedOdds(t *testing.T) { + const trials = 2_000_000 + + for _, ticket := range scratch.Catalog { + counts := map[string]int{} + for i := 0; i < trials; i++ { + var seed [32]byte + binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761) + out := ticket.Play(seed, 1000) + counts[out.TierName]++ + } + + for _, row := range ticket.Odds() { + expected := float64(row.Weight) / float64(row.Denominator) + observed := float64(counts[row.TierName]) / trials + // Allow a relative tolerance that scales with rarity, since rare + // tiers have proportionally more sampling noise. + tolerance := 0.02 + 3/(expected*trials+1) + if expected > 0 { + diff := (observed - expected) / expected + if diff < -tolerance || diff > tolerance { + t.Errorf("%s/%s: published %.6f, observed %.6f (%.1f%% off)", + ticket.ID, row.TierName, expected, observed, diff*100) + } + } + } + } +} + +// Empirical return must match the published RTP, which is the claim that +// actually matters to a player. +func TestEmpiricalReturnMatchesPublishedRTP(t *testing.T) { + const trials = 2_000_000 + const stake = 10_000 + + for _, ticket := range scratch.Catalog { + var paid int64 + for i := 0; i < trials; i++ { + var seed [32]byte + binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761) + paid += ticket.Play(seed, stake).PayoutMsat + } + staked := int64(trials) * stake + observedBP := paid * 10000 / staked + published := int64(ticket.RTPBasisPoints()) + if observedBP < published-150 || observedBP > published+150 { + t.Errorf("%s: observed RTP %d bp, published %d bp", + ticket.ID, observedBP, published) + } + } +} + +func TestOutcomeIsDeterministic(t *testing.T) { + ticket := scratch.Catalog[0] + var seed [32]byte + copy(seed[:], "a-fixed-seed-for-this-ticket-abc") + first := ticket.Play(seed, 5000) + for i := 0; i < 100; i++ { + if got := ticket.Play(seed, 5000); got.TierName != first.TierName || + got.PayoutMsat != first.PayoutMsat { + t.Fatal("scratch outcome is not deterministic") + } + } +} + +// The revealed cells must never contradict the payout: a winning ticket shows +// three of a kind, a losing ticket does not. +func TestRevealedCellsAgreeWithPayout(t *testing.T) { + for _, ticket := range scratch.Catalog { + for i := 0; i < 20000; i++ { + var seed [32]byte + binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761) + for j := 8; j < 32; j++ { + seed[j] = byte(i*j + j) + } + out := ticket.Play(seed, 1000) + + counts := map[int]int{} + for _, c := range out.Cells { + counts[c]++ + } + hasThree := false + for _, n := range counts { + if n >= 3 { + hasThree = true + break + } + } + if out.PayoutBP > 0 && !hasThree { + t.Fatalf("%s: winning ticket (%s) shows no three-of-a-kind: %v", + ticket.ID, out.TierName, out.Cells) + } + if out.PayoutBP == 0 && hasThree { + t.Fatalf("%s: losing ticket shows three-of-a-kind: %v", + ticket.ID, out.Cells) + } + } + } +} + +// Scratch tickets must be verifiable by the same commit-reveal path as the +// crash games. +func TestPlayFromRoundIsVerifiable(t *testing.T) { + ticket := scratch.Catalog[0] + server := fair.NewServerSeed() + commitment := server.Commitment() + pubkey := []byte("player-pubkey") + + out, proof := scratch.PlayFromRound(ticket, server, pubkey, 3, 1000) + + if !fair.VerifyCommitment(commitment, server) { + t.Fatal("commitment does not verify") + } + // Independently recompute the outcome the way a client would. + seed := fair.RoundSeed(server, fair.ClientSeed([][]byte{pubkey}), 3) + if recomputed := ticket.Play(seed, 1000); recomputed.TierName != out.TierName { + t.Fatalf("recomputed %q, server said %q", recomputed.TierName, out.TierName) + } + if proof.Nonce != 3 { + t.Fatalf("proof nonce = %d, want 3", proof.Nonce) + } +}