// 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), } }