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

@@ -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()