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>
213 lines
6.2 KiB
Go
213 lines
6.2 KiB
Go
package room
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Reconciler refunds rounds that were abandoned mid-flight.
|
|
//
|
|
// Stakes are debited when a bet is placed, so if the instance driving a game
|
|
// dies between the bet and settlement, those stakes sit with the house and the
|
|
// round never resolves. The books stay balanced — nothing is created or lost —
|
|
// but the players are quietly short, which is the same thing as being robbed
|
|
// by accident.
|
|
//
|
|
// This finds those rounds and refunds every unsettled stake. It is safe to run
|
|
// repeatedly and from any instance: refunds are recorded against the round, and
|
|
// a round already marked settled is skipped.
|
|
type Reconciler struct {
|
|
pool *pgxpool.Pool
|
|
ledger *ledger.Ledger
|
|
|
|
// Stale is how long a round may remain unsettled before it is considered
|
|
// abandoned. It must exceed the longest possible round plus the time it
|
|
// takes another instance to take over, or a live round would be refunded
|
|
// out from under the players still in it.
|
|
Stale time.Duration
|
|
}
|
|
|
|
func NewReconciler(pool *pgxpool.Pool, l *ledger.Ledger) *Reconciler {
|
|
return &Reconciler{
|
|
pool: pool,
|
|
ledger: l,
|
|
// A round is capped at 60s of flight plus its betting and settle
|
|
// phases; leadership moves within LeaseTTL. Two minutes is far past
|
|
// any legitimate round and still prompt enough to matter at a party.
|
|
Stale: 2 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// Result describes what a reconciliation pass did.
|
|
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.
|
|
func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
|
|
var res Result
|
|
|
|
rows, err := rc.pool.Query(ctx, `
|
|
SELECT DISTINCT r.id
|
|
FROM rounds r
|
|
JOIN bets b ON b.round_id = r.id
|
|
WHERE r.settled_at IS NULL
|
|
AND r.voided_at IS NULL
|
|
AND b.settled_at IS NULL
|
|
AND r.opened_at < now() - make_interval(secs => $1)
|
|
ORDER BY r.id`,
|
|
rc.Stale.Seconds())
|
|
if err != nil {
|
|
return res, fmt.Errorf("finding abandoned rounds: %w", err)
|
|
}
|
|
|
|
var roundIDs []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return res, err
|
|
}
|
|
roundIDs = append(roundIDs, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
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 {
|
|
// One bad round must not stop the rest from being made whole.
|
|
fmt.Printf("reconcile: round %d: %v\n", roundID, err)
|
|
continue
|
|
}
|
|
if refunded > 0 {
|
|
res.RoundsRefunded++
|
|
res.BetsRefunded += refunded
|
|
res.MsatRefunded += msat
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// refundRound returns every unsettled stake in one round.
|
|
func (rc *Reconciler) refundRound(ctx context.Context, roundID int64) (int, int64, error) {
|
|
// Claim the round by voiding it. Doing this before moving money means a
|
|
// second pass — or another instance running concurrently — finds nothing
|
|
// to do, so a refund cannot be issued twice.
|
|
//
|
|
// Void, not settled: an abandoned round produced no outcome, so it has no
|
|
// seed to reveal and must not masquerade as a resolved round.
|
|
tag, err := rc.pool.Exec(ctx,
|
|
`UPDATE rounds SET voided_at = now()
|
|
WHERE id = $1 AND settled_at IS NULL AND voided_at IS NULL`, roundID)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("claiming round: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return 0, 0, nil // another pass got there first
|
|
}
|
|
|
|
rows, err := rc.pool.Query(ctx,
|
|
`SELECT account_id, stake_msat FROM bets
|
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
type refund struct {
|
|
account int64
|
|
msat int64
|
|
}
|
|
var refunds []refund
|
|
for rows.Next() {
|
|
var r refund
|
|
if err := rows.Scan(&r.account, &r.msat); err != nil {
|
|
rows.Close()
|
|
return 0, 0, err
|
|
}
|
|
refunds = append(refunds, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if len(refunds) == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
|
|
house, err := rc.ledger.AccountByName(ctx, "house_pot")
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
postings := make([]ledger.Posting, 0, len(refunds)+1)
|
|
var total int64
|
|
for _, r := range refunds {
|
|
postings = append(postings, ledger.Posting{AccountID: r.account, AmountMsat: r.msat})
|
|
total += r.msat
|
|
}
|
|
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -total})
|
|
|
|
rid := roundID
|
|
if _, err := rc.ledger.Post(ctx, "refund_abandoned", &rid, postings); err != nil {
|
|
return 0, 0, fmt.Errorf("posting refunds: %w", err)
|
|
}
|
|
|
|
if _, err := rc.pool.Exec(ctx,
|
|
`UPDATE bets SET settled_at = now(), payout_msat = stake_msat
|
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID); err != nil {
|
|
return 0, 0, fmt.Errorf("marking bets refunded: %w", err)
|
|
}
|
|
|
|
return len(refunds), total, nil
|
|
}
|
|
|
|
// RunPeriodically sweeps for abandoned rounds until the context ends.
|
|
func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) {
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
res, err := rc.Run(ctx)
|
|
if err != nil {
|
|
fmt.Printf("reconcile: %v\n", err)
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|