At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the rarest and most valuable outcome silently became an instant 1.00x loss. At u=2 it produced a 2.1-billion-times payout the house could never cover, which would have left settlement failing and the player unpaid. The crash point is now capped at the largest multiplier the curve can express, which is unreachable anyway since the round hits its tick ceiling first. FromInt now panics outside the Q32.32 integer range instead of wrapping a positive input into a negative value. Raises coverage to 88% overall; adds a Makefile with db-reset, since the append-only ledger steadily consumes bridge headroom across test runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
6.3 KiB
Go
216 lines
6.3 KiB
Go
package fair_test
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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
|
|
}
|
|
}
|
|
|
|
func TestServerSeedRoundTripsThroughBytes(t *testing.T) {
|
|
original := fair.NewServerSeed()
|
|
restored := fair.ServerSeedFromBytes(original.Bytes())
|
|
|
|
if restored.Bytes() != original.Bytes() {
|
|
t.Fatal("seed did not survive a byte round trip")
|
|
}
|
|
if restored.Commitment() != original.Commitment() {
|
|
t.Fatal("restored seed produces a different commitment")
|
|
}
|
|
if restored.Hex() != original.Hex() {
|
|
t.Fatal("restored seed renders differently")
|
|
}
|
|
}
|
|
|
|
func TestHexIsFullLength(t *testing.T) {
|
|
s := fair.NewServerSeed()
|
|
if len(s.Hex()) != 64 {
|
|
t.Fatalf("hex seed is %d characters, want 64", len(s.Hex()))
|
|
}
|
|
}
|
|
|
|
func TestBuildProofIsSelfConsistent(t *testing.T) {
|
|
server := fair.NewServerSeed()
|
|
keys := [][]byte{[]byte("alice"), []byte("bob"), []byte("carol")}
|
|
const nonce = 17
|
|
|
|
proof := fair.BuildProof(server, keys, nonce)
|
|
|
|
if proof.Nonce != nonce {
|
|
t.Fatalf("proof nonce = %d, want %d", proof.Nonce, nonce)
|
|
}
|
|
if len(proof.Participants) != len(keys) {
|
|
t.Fatalf("proof lists %d participants, want %d", len(proof.Participants), len(keys))
|
|
}
|
|
// Every value in the proof must be reproducible from the others.
|
|
seedBytes, err := hex.DecodeString(proof.ServerSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(seedBytes)
|
|
if hex.EncodeToString(sum[:]) != proof.Commitment {
|
|
t.Fatal("proof commitment does not match its own seed")
|
|
}
|
|
var restored [32]byte
|
|
copy(restored[:], seedBytes)
|
|
want := fair.RoundSeed(fair.ServerSeedFromBytes(restored), fair.ClientSeed(keys), nonce)
|
|
if hex.EncodeToString(want[:]) != proof.RoundSeed {
|
|
t.Fatal("proof round seed does not follow from its inputs")
|
|
}
|
|
}
|
|
|
|
func TestProofParticipantsPreserveOrder(t *testing.T) {
|
|
server := fair.NewServerSeed()
|
|
keys := [][]byte{[]byte("first"), []byte("second")}
|
|
proof := fair.BuildProof(server, keys, 1)
|
|
|
|
if proof.Participants[0] != hex.EncodeToString(keys[0]) {
|
|
t.Fatal("participant order was not preserved")
|
|
}
|
|
if proof.Participants[1] != hex.EncodeToString(keys[1]) {
|
|
t.Fatal("participant order was not preserved")
|
|
}
|
|
}
|
|
|
|
// Reordering the same players must change the seed, since order is part of the
|
|
// commitment. Otherwise a player could be swapped in without detection.
|
|
func TestParticipantOrderAffectsTheSeed(t *testing.T) {
|
|
a, b := []byte("alice"), []byte("bob")
|
|
if fair.ClientSeed([][]byte{a, b}) == fair.ClientSeed([][]byte{b, a}) {
|
|
t.Fatal("reordering participants did not change the client seed")
|
|
}
|
|
}
|
|
|
|
// Length-prefixing must prevent two different participant lists from colliding
|
|
// through simple concatenation.
|
|
func TestClientSeedResistsConcatenationCollisions(t *testing.T) {
|
|
// Without length prefixes, {"ab","c"} and {"a","bc"} would hash the same.
|
|
one := fair.ClientSeed([][]byte{[]byte("ab"), []byte("c")})
|
|
two := fair.ClientSeed([][]byte{[]byte("a"), []byte("bc")})
|
|
if one == two {
|
|
t.Fatal("different participant lists collided; length prefixing is broken")
|
|
}
|
|
}
|
|
|
|
func TestEmptyParticipantListIsStable(t *testing.T) {
|
|
if fair.ClientSeed(nil) != fair.ClientSeed([][]byte{}) {
|
|
t.Fatal("nil and empty participant lists disagree")
|
|
}
|
|
}
|
|
|
|
// A round with no players must still produce a valid, verifiable outcome.
|
|
func TestRoundWithNoPlayersStillVerifies(t *testing.T) {
|
|
server := fair.NewServerSeed()
|
|
commitment := server.Commitment()
|
|
proof := fair.BuildProof(server, nil, 5)
|
|
|
|
if !fair.VerifyCommitment(commitment, server) {
|
|
t.Fatal("empty round does not verify")
|
|
}
|
|
if proof.RoundSeed == "" {
|
|
t.Fatal("empty round produced no seed")
|
|
}
|
|
}
|