Two deductions on payouts: a percentage rake and flooring to whole satoshis. Neither can be hidden — every millisatoshi is a double-entry posting, so each appears as its own line in the player's history and the conservation check fails if any amount goes unaccounted. A rake makes the advertised 99% false, so EffectiveRTPBasisPoints computes what players actually receive and the disclosure page is rendered from it. A test simulates 200,000 rounds and asserts the published figure matches what was really paid. Fixes an overflow found by the tests: gross * RakeBP exceeds int64 for large payouts, so the multiplication is split into whole and remainder parts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
6.1 KiB
Go
196 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|