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:
drjones
2026-08-05 03:28:27 +00:00
parent 8045e37c16
commit 41c1bb2fdf
4 changed files with 625 additions and 0 deletions

127
pkg/fair/fair.go Normal file
View 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[:]),
}
}