diff --git a/pkg/fees/fees.go b/pkg/fees/fees.go new file mode 100644 index 0000000..63fd1df --- /dev/null +++ b/pkg/fees/fees.go @@ -0,0 +1,178 @@ +// Package fees computes the operator's cut and the rounding residue. +// +// Two deductions apply to money leaving the house: +// +// - A rake: a percentage of each payout, disclosed as a rate. +// - Rounding: payouts are floored to a whole unit, and the fractional +// remainder stays with the house. +// +// Neither is hidden, and neither can be hidden. Every millisatoshi in this +// system is a double-entry posting, and the ledger's conservation check fails +// if any amount is unaccounted for. So a rake and a rounding residue must each +// appear as their own posting against the house — which means they also appear +// in the player's own transaction history, itemised. The architecture makes +// silent skimming impossible rather than merely discouraged. +// +// The consequence worth stating plainly: a rake reduces the real return to +// player. A game advertising 99% that then takes 1% of wins does not return +// 99%. EffectiveRTPBasisPoints computes what players actually get, and the +// disclosure page is generated from it, so the published figure cannot drift +// from the code. +package fees + +import "fmt" + +// Schedule is the operator's fee configuration. +type Schedule struct { + // RakeBP is taken from each payout, in basis points. 100 = 1%. + RakeBP int64 + + // RoundToMsat floors payouts to a multiple of this. 1000 rounds to whole + // satoshis. Set to 1 (or 0) to disable rounding entirely. + RoundToMsat int64 + + // MinPayoutMsat is the smallest payout worth making. Below it, rounding + // would consume the whole amount, so the payout is suppressed and the + // player told why rather than silently paid nothing. + MinPayoutMsat int64 +} + +// DefaultSchedule is deliberately small. The rake is the operator's revenue; +// the rounding is a rounding, not a second rake. +func DefaultSchedule() Schedule { + return Schedule{ + RakeBP: 100, // 1% of winnings + RoundToMsat: 1_000, // whole satoshis + MinPayoutMsat: 1_000, + } +} + +// NoFees disables both deductions, for testing and for a house that wants to +// run the arcade at cost. +func NoFees() Schedule { + return Schedule{RakeBP: 0, RoundToMsat: 1, MinPayoutMsat: 0} +} + +// Split is the breakdown of a single payout. +type Split struct { + // GrossMsat is what the player won before deductions. + GrossMsat int64 + // RakeMsat is the operator's percentage. + RakeMsat int64 + // RoundingMsat is the fraction left behind by flooring to RoundToMsat. + RoundingMsat int64 + // NetMsat is what the player actually receives. + NetMsat int64 +} + +// HouseMsat is everything the operator keeps from this payout. +func (s Split) HouseMsat() int64 { return s.RakeMsat + s.RoundingMsat } + +// Valid reports whether the split accounts for every millisatoshi. A split +// that does not balance would corrupt the ledger, so this is asserted before +// any posting is written. +func (s Split) Valid() bool { + return s.NetMsat+s.RakeMsat+s.RoundingMsat == s.GrossMsat && + s.NetMsat >= 0 && s.RakeMsat >= 0 && s.RoundingMsat >= 0 +} + +// Apply splits a gross payout into the player's share and the house's. +func (sch Schedule) Apply(grossMsat int64) Split { + if grossMsat <= 0 { + return Split{} + } + + rake := mulDivFloor(grossMsat, sch.RakeBP, 10000) + afterRake := grossMsat - rake + + unit := sch.RoundToMsat + if unit < 1 { + unit = 1 + } + net := afterRake / unit * unit + rounding := afterRake - net + + // Below the minimum, paying out costs more in dust than it delivers. + // Suppressing it must still be accounted: the amount goes to the house + // and is visible as such, not quietly dropped. + if net < sch.MinPayoutMsat { + rounding += net + net = 0 + } + + s := Split{ + GrossMsat: grossMsat, + RakeMsat: rake, + RoundingMsat: rounding, + NetMsat: net, + } + if !s.Valid() { + // Unreachable by construction; a panic here beats a silent imbalance + // that would be discovered later as missing money. + panic(fmt.Sprintf("fees: split does not balance: %+v", s)) + } + return s +} + +// mulDivFloor computes v * num / den without overflowing int64. +// +// The direct form overflows for large payouts: a gross of 2.3e18 times a rake +// of 10000 basis points is 2.3e22, far past int64. Splitting the value into +// whole and remainder parts keeps every intermediate inside the range while +// producing the identical floored result. +func mulDivFloor(v, num, den int64) int64 { + if den == 0 { + return 0 + } + return (v/den)*num + (v%den)*num/den +} + +// EffectiveRTPBasisPoints is the real return to player, given a game's own RTP +// before fees. +// +// This is the number that belongs on the disclosure page. A game whose maths +// return 9900 and whose operator takes a 1% rake does not return 99%: winners +// hand back a percentage of what they win, so the realised return is lower. +// +// The rake applies only to payouts, so it scales the returned portion: +// +// effective = gameRTP * (1 - rake) +// +// Rounding is excluded here because its size depends on the payout amounts a +// player actually hits, and overstating it would be its own dishonesty. It is +// disclosed separately, in units, which is a claim that can be checked. +func (sch Schedule) EffectiveRTPBasisPoints(gameRTPBasisPoints int64) int64 { + return mulDivFloor(gameRTPBasisPoints, 10000-sch.RakeBP, 10000) +} + +// Disclosure is the machine-readable statement of what the operator takes. +// The public page is rendered from this, so the published terms are generated +// from the same values the code charges. +type Disclosure struct { + RakePercent string `json:"rake_percent"` + RoundingUnit string `json:"rounding_unit"` + MinPayout string `json:"minimum_payout"` + GameRTPPercent string `json:"game_rtp_percent"` + EffectiveRTP string `json:"effective_rtp_percent"` + WorstCaseRounding string `json:"worst_case_rounding_per_payout"` +} + +// Describe renders the schedule for publication. +func (sch Schedule) Describe(gameRTPBasisPoints int64) Disclosure { + unit := sch.RoundToMsat + if unit < 1 { + unit = 1 + } + return Disclosure{ + RakePercent: fmt.Sprintf("%.2f%%", float64(sch.RakeBP)/100), + RoundingUnit: fmt.Sprintf("%d msat (%.3f sats)", unit, float64(unit)/1000), + MinPayout: fmt.Sprintf("%d msat", sch.MinPayoutMsat), + GameRTPPercent: fmt.Sprintf("%.2f%%", + float64(gameRTPBasisPoints)/100), + EffectiveRTP: fmt.Sprintf("%.2f%%", + float64(sch.EffectiveRTPBasisPoints(gameRTPBasisPoints))/100), + // The most a single payout can lose to rounding is one unit less one. + WorstCaseRounding: fmt.Sprintf("%d msat (%.3f sats)", + unit-1, float64(unit-1)/1000), + } +} diff --git a/pkg/fees/fees_test.go b/pkg/fees/fees_test.go new file mode 100644 index 0000000..c615fa3 --- /dev/null +++ b/pkg/fees/fees_test.go @@ -0,0 +1,195 @@ +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) + } + } +}