_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round opening (B-09). open_new_round_if_needed now handles the IntegrityError
from ix_rounds_single_active (previous commit) by rolling back and using the
winner's round. Deviation from the plan in BUGS.md, which proposed making the
scheduler the only writer: that would mean the first bet after a cooldown
couldn't open a round, so both callers stay and a bounded retry was added
instead — a conflict where nothing is active yet just means the winner hadn't
committed, and a bet must not fail on that timing. get_active_round also logs
loudly if it ever sees more than one active round rather than silently picking
the newest.
The jackpot (B-11). It was participant_count * the *current* bet_amount_sats,
which overstated the pool (each stored bet is already net of that bet's network
fee) and silently rewrote the advertised jackpot of a round in progress whenever
an operator edited the bet amount. It now sums the participants' stored
bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted
from the winner's share and unknowable until the payout is built — is documented
in the code rather than promised away, since the comment there claimed exactness.
Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast /
persist, so no DB session is held across a network call (on SQLite that meant
holding the write lock for two unbounded round-trips). That restructuring is also
what makes the error handling placeable: it now catches Exception around the
chain work and writes a payout_failed audit entry, where a malformed fee_address
used to raise EmbitError all the way to the scheduler's catch-all, leaving the
round stuck in paying_out with nothing recorded about why. Automatic payout retry
remains an open gap.
The scheduler also counts "building" participants as in-flight when deciding
whether a round may close, matching the two-phase bet write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontend dashboards previously found out about state changes only on their
next poll tick (up to 15s, or 3s during a draw) — this adds a push channel
so updates land as soon as they happen instead.
- app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster).
The message carries no payload — it's just a "something changed, go
refetch" signal, so it needs no auth and no knowledge of who's allowed to
see what; personalization stays entirely in the existing REST endpoints.
- GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive
comments so it survives idle periods behind a reverse proxy, and a
defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users
expected) — past it, the endpoint returns 503 instead of opening a stream,
and callers just keep working off polling.
- publish() calls added at every point that actually changes what a
dashboard would want to know: new round opened, round status transitions
(closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any
pending tx confirming (bet/withdrawal/payout), a deposit credited, and a
new block tip arriving (the exact moment the "drawing" phase is waiting on).
- Caddyfile: excludes /rounds/stream from gzip encoding, since compression
would buffer output and defeat the point of a live stream.
Single-process only by design for now (no cross-worker fan-out) and the
notification is a generic broadcast rather than a per-user channel — both
are deliberate scope decisions for the current ~100-user, single-container
deployment, not oversights. Polling is left fully in place as a fallback;
this is purely additive.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Balance display: place_bet/request_withdrawal spend whole UTXOs and mark
them spent at broadcast time, well before confirmation, so the confirmed-only
balance could drop by far more than the amount actually moving. Add
compute_pending_balance() (app/wallet/balance.py) to fold the unconfirmed
change from in-flight bet/withdrawal PendingTransactions back in; GET
/users/me now returns pending_balance_sats + has_pending, and the frontend
shows it colored green (settled) or amber (still pending) instead of the
confirmed-only figure.
Round outcome display: the win/lose reveal and the "pagamento al vincitore
in corso" status were fighting over the same UI slot, and the reveal broke
across a page refresh. Now:
- The round-status box (generic phase progress) and the personal win/lose
box are independent and can both be visible at once.
- The win/lose box only renders for users who actually played in that round
(new user_played field on GET /rounds/current, via a new optional-auth
dependency so the endpoint stays usable logged-out).
- The reveal delay is anchored to the round's server-provided closes_at
instead of a client-side "first seen" timestamp, so repeated reloads can't
reset it, and the revealed result is persisted in localStorage so it
survives a refresh even after the round has fully closed.
- GET /users/me/last-round-result is a durable DB-backed backstop for
players who miss the live window entirely (backgrounded tab, offline).
Also hardens the frontend polling loop: call() now times out instead of
hanging forever, and a session-epoch counter stops an in-flight request from
a previous login from resurrecting a duplicate poll loop after logout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flip status to "closing" the instant the round timer expires, rather than
leaving it "open" (invisible) while in-flight bets confirm — /rounds/current
now surfaces this wait as its own phase instead of collapsing it into
"drawing". _tick() is updated to keep re-checking pending bets while status
is "closing" instead of short-circuiting on the old "status != open" guard.
Also re-stamp closed_at at actual payout confirmation time (not at the
earlier "closing" transition), since round_cooldown_seconds counts from
closed_at — with a short cooldown (e.g. 20s) and ~2 block-time draw+payout
wait, the old anchor meant the cooldown had already elapsed by the time the
round actually closed, making it a no-op.
Expose draw_block_height/draw_block_hash on GET /rounds/current so clients
can show which block the winner was drawn from.
GET /rounds/current's jackpot_sats was the full pool (participant_count *
bet_amount_sats), which overstates what the winner actually receives once
the 30% commission is taken out at payout time. Apply the same 70% split
rounds/scheduler.py uses so the number shown during the round already
matches the real payout, without surfacing the fee itself in the UI/docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The round timer relied on each client's own wall clock, so two browsers with
skewed local clocks showed different countdowns for the same round; the
server now also returns server_time so the frontend can correct for clock
skew. Also drop out-of-order /rounds/current responses (multiple independent
triggers could resolve late and revert the UI to a stale drawing/result
state) and prune per-round bookkeeping maps on round transitions.
Separately, place_bet only checked status == "open", leaving a window (up to
the scheduler's 5s tick interval) after a round's timer hit zero where a new
bet could still be accepted. place_bet now checks the round's own deadline
directly (round_accepts_bets), acting as an immediate "yellow light" for new
entries while still letting already-broadcast bets confirm before the round
closes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RoundConfig gets a paused flag toggled via new POST /admin/pause and
/admin/resume endpoints (audit-logged, surfaced as a "Manutenzione" card in
the admin Parametri view). Pausing only stops the *next* round from opening
once the current one closes — rounds/service.py:open_new_round_if_needed
still lets an in-progress round finish, draw, and pay out its winner
normally. GET /rounds/current exposes lottery_paused so the user page shows
a maintenance banner (even while logged out) instead of silently going idle.
Also replaces the user dashboard's stacked account-bar card + bento-grid
menu with a single sticky navbar (identity row + Deposito/Bet/Prelievo
tabs), and moves the page content into a dedicated .app-shell container so
the navbar itself can span full width.
The user page's login screen was a bare test dashboard with no explanation
of how the lottery works; it now leads with a hero (3-step explainer, trust
pills) shown only while logged out, plus a bento-style nav and a glowing
round card during the draw.
Both the user and admin pages now show the current chain tip height and
lottery status (open / drawing / waiting for next round) via a small status
strip, polled from /rounds/current (extended with chain_tip_height sourced
from ElectrumListener.tip_height).
RoundConfig gains draw_animation_seconds (default 20) — the minimum
time the frontend's "estrazione in corso" animation plays before
revealing a winner. It's purely a UI cue: the real draw still waits for
a confirmed block for its entropy (rounds/scheduler.py), which usually
takes much longer than this value, so it only ever extends the
animation, never truncates the real wait.
GET /rounds/current now also returns draw_animation_seconds,
winner_user_id and winner_amount_sats (all populated once the
scheduler sets them on the round, i.e. from "paying_out" onward) so a
client can determine and reveal the outcome. GET /users/me now returns
the user's own id, needed client-side to compare against winner_user_id.
Admin config CRUD refactored to a shared field tuple (_CONFIG_FIELDS)
instead of repeating the same 7-then-8 field list three times.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RoundConfig gains round_duration_seconds, round_cooldown_seconds,
min_amount_sats, fee_rate_sat_vb and rbf_timeout_seconds (plus a
hardcoded default for the pre-existing bet_amount_sats) as column
defaults on the model itself — get_round_config no longer seeds from
Settings at all. Every call site that read these from settings
(scheduler, bets, withdrawals, rounds service/route, RBF bumper) now
reads the DB-backed RoundConfig instead.
app/config.py now holds only true env-driven infra/secrets (database
URL, Electrum connection, master key, JWT, admin token) — no business
parameter has an env var anymore, matching an explicit decision to drop
the "seed from settings" indirection entirely rather than keep a env
fallback nobody should rely on.
Migration backfills the existing round_config row via server_default
(matching the old settings defaults) then drops the default, so future
rows go through the ORM/model defaults instead of a stale constant.
Tests updated: should_bump's timeout_seconds is now required (no
settings fallback); test_scheduler.py seeds a RoundConfig row directly
instead of monkeypatching settings; test_withdrawals.py and
test_rounds_service.py use local constants mirroring the model
defaults instead of reading them off settings.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /rounds/current returns the active round's id/status, opened_at/
closes_at (derived from ROUND_DURATION_SECONDS), participant count and
jackpot (participant_count * bet_amount_sats). No active round still
returns bet_amount_sats so clients can render a fixed-entry hint either
way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>