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

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