package fees_test import ( "math" "testing" "github.com/drjones/quantum-arcade/pkg/fees" ) // The property that matters most: every millisatoshi is accounted for. If a // split ever failed to balance, the ledger's conservation check would fail and // the arcade would be reporting corrupt books. func TestEveryMillisatoshiIsAccountedFor(t *testing.T) { schedules := []fees.Schedule{ fees.DefaultSchedule(), fees.NoFees(), {RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000}, {RakeBP: 1, RoundToMsat: 1, MinPayoutMsat: 0}, {RakeBP: 10000, RoundToMsat: 1_000, MinPayoutMsat: 0}, // 100% rake } amounts := []int64{0, 1, 999, 1_000, 1_001, 12_345, 1_000_000, 999_999_999, math.MaxInt64 / 4} for _, sch := range schedules { for _, gross := range amounts { s := sch.Apply(gross) if !s.Valid() { t.Fatalf("schedule %+v on %d produced an unbalanced split: %+v", sch, gross, s) } if s.NetMsat+s.HouseMsat() != gross { t.Fatalf("schedule %+v on %d: net %d + house %d != %d", sch, gross, s.NetMsat, s.HouseMsat(), gross) } } } } func TestRakeIsExactPercentage(t *testing.T) { sch := fees.Schedule{RakeBP: 100, RoundToMsat: 1, MinPayoutMsat: 0} s := sch.Apply(1_000_000) if s.RakeMsat != 10_000 { t.Fatalf("1%% of 1000000 = %d, want 10000", s.RakeMsat) } if s.NetMsat != 990_000 { t.Fatalf("net = %d, want 990000", s.NetMsat) } } func TestRoundingFloorsToTheUnit(t *testing.T) { sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 0} cases := []struct { gross, net, rounding int64 }{ {1_000, 1_000, 0}, {1_999, 1_000, 999}, {2_000, 2_000, 0}, {999, 0, 999}, } for _, c := range cases { s := sch.Apply(c.gross) if s.NetMsat != c.net || s.RoundingMsat != c.rounding { t.Errorf("gross %d: net %d rounding %d, want net %d rounding %d", c.gross, s.NetMsat, s.RoundingMsat, c.net, c.rounding) } } } // Rounding must never take more than one unit less one from a payout. That // bound is what makes the disclosure checkable. func TestRoundingIsBoundedByOneUnit(t *testing.T) { sch := fees.DefaultSchedule() for gross := int64(1_000); gross < 200_000; gross += 37 { s := sch.Apply(gross) if s.NetMsat > 0 && s.RoundingMsat >= sch.RoundToMsat { t.Fatalf("gross %d lost %d msat to rounding, more than one unit (%d)", gross, s.RoundingMsat, sch.RoundToMsat) } } } func TestNoFeesTakesNothing(t *testing.T) { sch := fees.NoFees() for _, gross := range []int64{1, 999, 1_000, 123_456} { s := sch.Apply(gross) if s.NetMsat != gross { t.Fatalf("gross %d returned %d with fees disabled", gross, s.NetMsat) } if s.HouseMsat() != 0 { t.Fatalf("house took %d with fees disabled", s.HouseMsat()) } } } func TestZeroAndNegativeGrossAreNoOps(t *testing.T) { sch := fees.DefaultSchedule() for _, gross := range []int64{0, -1, -100_000} { s := sch.Apply(gross) if s.NetMsat != 0 || s.HouseMsat() != 0 { t.Fatalf("gross %d produced %+v", gross, s) } } } // The published effective RTP must match what players actually receive over a // long run. This is the claim the disclosure page makes, so it gets checked // against simulated play rather than trusted. func TestPublishedEffectiveRTPMatchesReality(t *testing.T) { sch := fees.DefaultSchedule() const gameRTP = 9900 // the games' own 99% published := sch.EffectiveRTPBasisPoints(gameRTP) // Simulate: players stake, the games return 99% of stakes as gross // winnings, and the rake applies to those winnings. const rounds = 200_000 const stake = int64(100_000) // 100 sats, large enough that rounding is noise var staked, received int64 for i := 0; i < rounds; i++ { staked += stake gross := stake * gameRTP / 10000 received += sch.Apply(gross).NetMsat } observed := received * 10000 / staked if observed < published-20 || observed > published+20 { t.Fatalf("published effective RTP %d bp, players actually received %d bp", published, observed) } } // A rake must reduce the advertised return. Publishing the game's own RTP // while taking a cut would be a false claim. func TestRakeReducesTheAdvertisedReturn(t *testing.T) { const gameRTP = 9900 withFees := fees.DefaultSchedule().EffectiveRTPBasisPoints(gameRTP) withoutFees := fees.NoFees().EffectiveRTPBasisPoints(gameRTP) if withoutFees != gameRTP { t.Fatalf("with no fees the effective RTP should equal the game RTP, got %d", withoutFees) } if withFees >= gameRTP { t.Fatalf("effective RTP %d is not below the game's %d despite a rake", withFees, gameRTP) } } // Small payouts must not be silently swallowed: whatever is suppressed has to // show up on the house side of the split. func TestSuppressedPayoutIsStillAccounted(t *testing.T) { sch := fees.Schedule{RakeBP: 0, RoundToMsat: 1_000, MinPayoutMsat: 10_000} s := sch.Apply(5_000) if s.NetMsat != 0 { t.Fatalf("net = %d, want 0 below the minimum", s.NetMsat) } if s.HouseMsat() != 5_000 { t.Fatalf("house = %d, want the full 5000 that was suppressed", s.HouseMsat()) } if !s.Valid() { t.Fatal("suppressed payout produced an unbalanced split") } } // The disclosure must be generated from the same values that are charged, so // the published terms cannot drift from the code. func TestDisclosureReflectsTheSchedule(t *testing.T) { sch := fees.Schedule{RakeBP: 250, RoundToMsat: 1_000, MinPayoutMsat: 1_000} d := sch.Describe(9900) if d.RakePercent != "2.50%" { t.Errorf("rake disclosed as %q, want 2.50%%", d.RakePercent) } // 99% game RTP with a 2.5% rake leaves 96.52%. if d.EffectiveRTP != "96.52%" { t.Errorf("effective RTP disclosed as %q, want 96.52%%", d.EffectiveRTP) } if d.WorstCaseRounding != "999 msat (0.999 sats)" { t.Errorf("worst-case rounding disclosed as %q", d.WorstCaseRounding) } } // Extreme configurations must not overflow or produce nonsense. func TestExtremeSchedulesAreSafe(t *testing.T) { huge := int64(math.MaxInt64 / 2) for _, sch := range []fees.Schedule{ {RakeBP: 10000, RoundToMsat: 1, MinPayoutMsat: 0}, {RakeBP: 0, RoundToMsat: huge, MinPayoutMsat: 0}, {RakeBP: 0, RoundToMsat: 0, MinPayoutMsat: 0}, // unit floor of 1 } { s := sch.Apply(1_000_000) if !s.Valid() { t.Fatalf("schedule %+v produced %+v", sch, s) } } }