6 Commits

Author SHA1 Message Date
drjones
2eafcbfd68 perf(ledger): batch writes behind in-memory reservations
Raises bet throughput from ~166 to ~318 per second, measured. A 20-second
betting window now absorbs roughly 6,400 bets instead of 3,300.

A bet reserves synchronously in memory and writes in the background. The
reservation counts against the balance immediately, so two concurrent
spends of the same funds cannot both succeed while the first sits in the
buffer — twelve goroutines racing for one balance yield exactly one
winner.

The first attempt was slower than no batching at all, because Flush still
called Post per transaction and each kept its own commit. Amortising the
scheduling is worthless; the fsync is the cost. PostMany now writes the
whole batch in one database transaction, and a rejected group falls back
to individual writes to isolate the offender.

Safety rests on co-location: the reservation buffer and the round live in
the same process, so a crash loses both together — the player was not
charged and is not in the round. A round that flushed and then lost its
process is already handled by the reconciler.

Fixes a data race the detector found: MaxDelay was a public mutable field
read by the flush loop, so any operator tuning it live would have raced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:26:13 +00:00
drjones
8af6fd585e feat(tournament): scheduled events with prize pools
Entry fees collect into a real ledger account rather than a number in a
row, so tournament money obeys the same double-entry invariants as
everything else and every movement is explained by a posting.

Settlement distributes the entire pool: dividing a pool across percentage
shares leaves a remainder, and dropping it would destroy money and break
conservation, so it goes to first place. Settlement claims the tournament
before paying, so two instances cannot both pay out. Cancellation refunds
every entrant and asserts the pool empties exactly.

18 tests including concurrent entry, concurrent settlement, unfunded
entry taking no seat, and books balancing after payout.

Removes an append-only trigger that had been over-applied to entry rows.
An entry is a seat reservation, not a financial record: a seat claimed
but unpaid must be releasable so the player can retry once funded. The
money side stays immutable because it is a ledger posting.

The journey test now derives the expected payout from the published fee
schedule instead of hardcoding it, so it keeps checking something real if
the rake changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:54:18 +00:00
drjones
038550b6ff perf: single-statement postings, marshal-once broadcast, client interpolation
Measured, then fixed, the three things that made a crowd impossible.

Ledger: Post issued three round trips per posting, so settlement scaled
in network latency rather than work. It is now two statements regardless
of leg count — settling 1000 winners went 844ms to 220ms. The lock and
the balance read must stay separate statements: a single statement, even
one whose CTE does FOR UPDATE, evaluates against a snapshot taken before
the locks are held, so concurrent transactions read stale balances and
money disappears. The conservation tests caught exactly that.

Broadcast: every connection marshalled its own copy, ~355us each. At any
real crowd that exceeds the tick interval by orders of magnitude. Frames
are now serialised once per broadcast and shared.

Feed: the player list is capped at 24 and carries no public keys, and
running rounds broadcast at 5Hz instead of 60Hz. Clients compute the
multiplier locally from the round start time, which the deterministic
curve makes exact. Frame size fell from 3.6KB to 1.8KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:36:30 +00:00
drjones
dee3becd47 fix(sim): cap crash point so extreme seeds cannot overflow or bankrupt
At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the
rarest and most valuable outcome silently became an instant 1.00x loss.
At u=2 it produced a 2.1-billion-times payout the house could never
cover, which would have left settlement failing and the player unpaid.
The crash point is now capped at the largest multiplier the curve can
express, which is unreachable anyway since the round hits its tick
ceiling first.

FromInt now panics outside the Q32.32 integer range instead of wrapping
a positive input into a negative value.

Raises coverage to 88% overall; adds a Makefile with db-reset, since the
append-only ledger steadily consumes bridge headroom across test runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:52:50 +00:00
drjones
f2c02e2bde fix(ledger): reject posting sets that overflow the zero-sum check
An adversarial posting set of two MaxInt64 legs plus one of 2 wraps to
zero in int64 arithmetic, so the balance check passed and the ledger
minted 18 quintillion millisatoshis from nothing. The sum is now
accumulated in big.Int, per-account balance arithmetic is checked for
wraparound, and the audit totals parse through big.Int so a corrupt
ledger reports a clear error rather than failing to scan.

Adds room package tests (0% -> covered) and ledger edge cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:43:21 +00:00
drjones
8045e37c16 feat(ledger): add append-only double-entry engine
The Lightning bridge is modelled as the boundary with the outside
world and is the one account permitted to go negative; its negative
balance is exactly what is owed to players inside the system. All
other accounts are floored at zero by both the application and a
database trigger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:23:49 +00:00