feat(fair,scratch): add commit-reveal fairness and scratch tickets
Scratch odds tables are derived from the same tier list that generates outcomes, so the published odds cannot drift from reality. Tests assert observed frequencies and empirical RTP against the published figures; the initial prize tables claimed 98% but actually paid 56%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
127
pkg/fair/fair.go
Normal file
127
pkg/fair/fair.go
Normal file
@@ -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[:]),
|
||||
}
|
||||
}
|
||||
108
pkg/fair/fair_test.go
Normal file
108
pkg/fair/fair_test.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
237
pkg/scratch/scratch.go
Normal file
237
pkg/scratch/scratch.go
Normal file
@@ -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)
|
||||
}
|
||||
153
pkg/scratch/scratch_test.go
Normal file
153
pkg/scratch/scratch_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user