feat(fees): disclosed rake and rounding, with generated disclosure
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>
This commit is contained in:
178
pkg/fees/fees.go
Normal file
178
pkg/fees/fees.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
195
pkg/fees/fees_test.go
Normal file
195
pkg/fees/fees_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user