feat(admin): operations console, fees wired into payouts
Fees now flow through settlement. The payout and the deduction are posted as separate ledger transactions rather than netted, so a player's history shows the full win and the charge as itemised lines instead of a quietly smaller win. The admin console shows treasury, liability, revenue, every posting, every round, and risk flags. Auth is a constant-time token compare and the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a default deployment has no admin endpoint to attack. The token lives in browser memory only. It is read-only over game outcomes by design: seeds show only after settlement and nothing can alter a crash point. A control that could would make the fairness proof a lie. The console immediately found a real bug: 343 unresolved rounds, because the reconciler only considered rounds with bets and abandoned empty ones accumulated forever, burying the signal. Now cleared automatically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,8 @@ type Result struct {
|
||||
RoundsRefunded int
|
||||
BetsRefunded int
|
||||
MsatRefunded int64
|
||||
// EmptyRoundsClosed counts abandoned rounds that nobody had joined.
|
||||
EmptyRoundsClosed int
|
||||
}
|
||||
|
||||
// Run refunds every abandoned round it finds.
|
||||
@@ -81,6 +83,22 @@ func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Rounds that nobody joined also need closing. They hold no money, so
|
||||
// there is nothing to refund, but leaving them open forever makes the
|
||||
// operator's "unresolved rounds" signal useless — it climbs steadily with
|
||||
// noise and stops meaning anything when a real one appears.
|
||||
empty, err := rc.pool.Exec(ctx, `
|
||||
UPDATE rounds SET voided_at = now()
|
||||
WHERE settled_at IS NULL
|
||||
AND voided_at IS NULL
|
||||
AND opened_at < now() - make_interval(secs => $1)
|
||||
AND NOT EXISTS (SELECT 1 FROM bets WHERE bets.round_id = rounds.id)`,
|
||||
rc.Stale.Seconds())
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("closing empty rounds: %w", err)
|
||||
}
|
||||
res.EmptyRoundsClosed = int(empty.RowsAffected())
|
||||
|
||||
for _, roundID := range roundIDs {
|
||||
refunded, msat, err := rc.refundRound(ctx, roundID)
|
||||
if err != nil {
|
||||
@@ -183,9 +201,11 @@ func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration)
|
||||
fmt.Printf("reconcile: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if res.RoundsRefunded > 0 {
|
||||
fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds (%d msat)\n",
|
||||
res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded)
|
||||
if res.RoundsRefunded > 0 || res.EmptyRoundsClosed > 0 {
|
||||
fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds "+
|
||||
"(%d msat); closed %d empty rounds\n",
|
||||
res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded,
|
||||
res.EmptyRoundsClosed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||
@@ -118,6 +119,11 @@ type Room struct {
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
|
||||
// Fees is the operator's schedule. Deductions are posted as their own
|
||||
// ledger transaction rather than folded into the payout, so a player's
|
||||
// history shows the win and the fee as separate, itemised lines.
|
||||
Fees fees.Schedule
|
||||
|
||||
mu sync.RWMutex
|
||||
roundID int64
|
||||
state State
|
||||
@@ -142,6 +148,7 @@ func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||
Game: game,
|
||||
pool: pool,
|
||||
ledger: l,
|
||||
Fees: fees.DefaultSchedule(),
|
||||
state: StateSettled,
|
||||
bets: make(map[int64]*Bet),
|
||||
subscribers: make(map[chan []byte]struct{}),
|
||||
@@ -342,33 +349,56 @@ func (r *Room) settle(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var postings []ledger.Posting
|
||||
var housePays int64
|
||||
var payouts []ledger.Posting
|
||||
var feeLines []ledger.Posting
|
||||
var housePays, houseKeeps int64
|
||||
|
||||
for _, b := range bets {
|
||||
if b.CashedOutAt == 0 {
|
||||
continue // rode it into the crash; the stake already sits with the house
|
||||
}
|
||||
payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
|
||||
b.PayoutMsat = payout
|
||||
if payout > 0 {
|
||||
postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout})
|
||||
housePays += payout
|
||||
gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
|
||||
split := r.Fees.Apply(gross)
|
||||
b.PayoutMsat = split.NetMsat
|
||||
|
||||
if gross > 0 {
|
||||
payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross})
|
||||
housePays += gross
|
||||
}
|
||||
if split.HouseMsat() > 0 {
|
||||
feeLines = append(feeLines, ledger.Posting{
|
||||
AccountID: b.AccountID, AmountMsat: -split.HouseMsat()})
|
||||
houseKeeps += split.HouseMsat()
|
||||
}
|
||||
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE bets SET payout_msat = $2, settled_at = now()
|
||||
`UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5,
|
||||
settled_at = now()
|
||||
WHERE round_id = $1 AND account_id = $3`,
|
||||
roundID, payout, b.AccountID); err != nil {
|
||||
roundID, split.NetMsat, b.AccountID,
|
||||
split.RakeMsat, split.RoundingMsat); err != nil {
|
||||
return fmt.Errorf("recording payout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rid := roundID
|
||||
|
||||
// Pay the full winnings first, then take the fee as its own transaction.
|
||||
// Netting them into one posting would be arithmetically identical but
|
||||
// would hide the deduction: the player would see a smaller win rather
|
||||
// than a win and a charge.
|
||||
if housePays > 0 {
|
||||
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||
rid := roundID
|
||||
if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil {
|
||||
payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
||||
if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil {
|
||||
return fmt.Errorf("settling round %d: %w", roundID, err)
|
||||
}
|
||||
}
|
||||
if houseKeeps > 0 {
|
||||
feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps})
|
||||
if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil {
|
||||
return fmt.Errorf("collecting fees for round %d: %w", roundID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
|
||||
seedBytes := seed.Bytes()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||
"github.com/drjones/quantum-arcade/pkg/fees"
|
||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||
@@ -996,3 +997,201 @@ func TestBooksBalanceAfterRefund(t *testing.T) {
|
||||
t.Fatalf("books do not balance after refunds: %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- operating fees ---------------- */
|
||||
|
||||
// A winning player must receive the payout minus the disclosed fee, and the
|
||||
// deduction must appear as its own ledger entry rather than being folded
|
||||
// silently into a smaller win.
|
||||
func TestFeesAreDeductedAndItemised(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||
hp, _ := f.player("housefund", 50_000_000)
|
||||
if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, pk := f.player("a", 10_000_000)
|
||||
f.room.Fees = fees.Schedule{RakeBP: 100, RoundToMsat: 1_000, MinPayoutMsat: 1_000}
|
||||
|
||||
f.openBetting()
|
||||
const stake = 1_000_000
|
||||
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.startRun()
|
||||
f.forceCrashPoint(100)
|
||||
f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2)))
|
||||
at, err := f.room.CashOut(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
if err := f.room.settle(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
gross := stake * int64(at) / int64(fixed.One)
|
||||
want := f.room.Fees.Apply(gross)
|
||||
if got := after - before; got != want.NetMsat {
|
||||
t.Fatalf("player received %d, want %d net of fees (gross %d)",
|
||||
got, want.NetMsat, gross)
|
||||
}
|
||||
|
||||
// The history must show the win and the charge separately.
|
||||
entries, err := f.ledger.History(f.ctx, id, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawPayout, sawFee bool
|
||||
for _, e := range entries {
|
||||
if e.Kind == "payout" && e.AmountMsat == gross {
|
||||
sawPayout = true
|
||||
}
|
||||
if e.Kind == "operating_fee" && e.AmountMsat == -want.HouseMsat() {
|
||||
sawFee = true
|
||||
}
|
||||
}
|
||||
if !sawPayout {
|
||||
t.Error("history does not show the full payout")
|
||||
}
|
||||
if !sawFee {
|
||||
t.Error("history does not itemise the operating fee")
|
||||
}
|
||||
}
|
||||
|
||||
// The books must still balance once fees are being taken.
|
||||
func TestBooksBalanceWithFees(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||
hp, _ := f.player("housefund", 50_000_000)
|
||||
if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.room.Fees = fees.DefaultSchedule()
|
||||
f.openBetting()
|
||||
var ids []int64
|
||||
for i := 0; i < 5; i++ {
|
||||
id, pk := f.player(fmt.Sprintf("p%d", i), 5_000_000)
|
||||
if err := f.room.PlaceBet(f.ctx, id, pk, "p", 500_000, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
f.startRun()
|
||||
f.forceCrashPoint(100)
|
||||
f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(3)))
|
||||
for i, id := range ids {
|
||||
if i%2 == 0 {
|
||||
if _, err := f.room.CashOut(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := f.room.settle(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("books do not balance with fees enabled: %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// With fees disabled the player must receive the full payout.
|
||||
func TestNoFeesPaysFullAmount(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||
hp, _ := f.player("housefund", 50_000_000)
|
||||
if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, pk := f.player("a", 10_000_000)
|
||||
f.room.Fees = fees.NoFees()
|
||||
|
||||
f.openBetting()
|
||||
const stake = 1_000_000
|
||||
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.startRun()
|
||||
f.forceCrashPoint(100)
|
||||
f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2)))
|
||||
at, _ := f.room.CashOut(id)
|
||||
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
if err := f.room.settle(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
gross := stake * int64(at) / int64(fixed.One)
|
||||
if got := after - before; got != gross {
|
||||
t.Fatalf("player received %d with fees disabled, want the full %d", got, gross)
|
||||
}
|
||||
}
|
||||
|
||||
// Rounds nobody joined must also be closed, or the operator's unresolved-round
|
||||
// signal fills with noise and stops meaning anything.
|
||||
func TestEmptyAbandonedRoundsAreClosed(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rc := NewReconciler(f.room.pool, f.ledger)
|
||||
rc.Stale = 0
|
||||
|
||||
// Open rounds and never settle them; nobody bets.
|
||||
for i := 0; i < 3; i++ {
|
||||
f.openBetting()
|
||||
}
|
||||
|
||||
res, err := rc.Run(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.EmptyRoundsClosed < 3 {
|
||||
t.Fatalf("closed %d empty rounds, want at least 3", res.EmptyRoundsClosed)
|
||||
}
|
||||
|
||||
var stillOpen int
|
||||
if err := f.room.pool.QueryRow(f.ctx,
|
||||
`SELECT count(*) FROM rounds
|
||||
WHERE settled_at IS NULL AND voided_at IS NULL`).Scan(&stillOpen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stillOpen != 0 {
|
||||
t.Fatalf("%d rounds remain unresolved after reconciliation", stillOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// Closing empty rounds must not touch rounds that have players in them.
|
||||
func TestEmptyRoundClosureSpareRoundsWithBets(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rc := NewReconciler(f.room.pool, f.ledger)
|
||||
rc.Stale = 2 * time.Minute // nothing is stale yet
|
||||
|
||||
id, pk := f.player("a", 100_000)
|
||||
f.openBetting()
|
||||
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roundID := f.room.roundID
|
||||
|
||||
if _, err := rc.Run(f.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var voided *time.Time
|
||||
if err := f.room.pool.QueryRow(f.ctx,
|
||||
`SELECT voided_at FROM rounds WHERE id = $1`, roundID).Scan(&voided); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if voided != nil {
|
||||
t.Fatal("a live round with a player in it was voided")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user