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:
drjones
2026-08-05 23:34:16 +00:00
parent e70258c54d
commit 1da3b6760e
9 changed files with 1315 additions and 15 deletions

View File

@@ -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")
}
}