Bring docs in sync with recent features (pending balance, SSE, per-player reveal)

CLAUDE.md: bumped the stale test count (54 -> 76), added "Balance display"
and "Real-time updates (SSE)" sections, and rewrote the DRAW section's
frontend-reveal paragraph to describe the actual current behavior (dual
status/result boxes gated by user_played, closes_at-anchored reveal delay,
localStorage persistence, the last-round-result backstop) instead of the
older single-box design. Refined the "no history endpoints" known gap now
that GET /users/me/last-round-result exists (still not general history).

README.md: same test count fix, expanded coverage list.

docs/: fixed a pre-existing broken link in setup.md (admin-guide.md ->
guida-admin.md), added a note in running-the-server.md that editing the
bind-mounted Caddyfile needs an explicit `docker compose restart caddy`
(discovered while adding the SSE Caddy config in a prior change), and
rewrote guida-utente.md's draw/reveal section plus the balance/withdrawal
sections to match what the UI actually does now. guida-admin.md was
reviewed but needed no changes.

app/static/style.css: dropped `.toast.info`, dead since the toast-based
loss notification it styled was replaced by the persistent result box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:05:27 +02:00
co-authored by Claude Sonnet 5
parent dda5bd14e1
commit aae0961c94
6 changed files with 80 additions and 26 deletions
+23 -4
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (54 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), and a static test UI for the user-facing flow (`/`).
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (76 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below).
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
@@ -81,6 +81,25 @@ Mainnet:
- Block time: 120s
- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs`
## Balance display
`place_bet`/`request_withdrawal` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving.
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only — still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true).
## Real-time updates (SSE)
`GET /rounds/stream` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints.
`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub — one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py` — the exact moment the "drawing" phase is waiting on).
Deliberate scope decisions, not oversights:
- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed.
- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency.
- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps).
Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap.
## MVP business parameters
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
@@ -99,7 +118,7 @@ The flow is organized into 5 phases, each a subgraph in [flowchart.mmd](flowchar
- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals.
- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a "drawing" animation on every user's dashboard for at least `draw_animation_seconds` (admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set; see `GET /rounds/current`'s `winner_user_id`/`winner_amount_sats` and `app/static/index.html`'s reveal logic.
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic.
- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
@@ -113,7 +132,7 @@ So worst case (last bet confirms right at the deadline) is ~3 block times end-to
## Admin dashboard and test UI
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework:
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler):
- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu.
- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL.
@@ -139,7 +158,7 @@ Not blockers for reading the code, but must be addressed before this is producti
- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with no automatic retry.
- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
- **No user-facing history endpoints** (list my own bets / withdrawals / past rounds) — a user still only has `/users/me` (balance). The admin side now has this (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's no equivalent scoped to "my own history" for a logged-in user.
- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user.
- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table records *what* changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger.
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin).
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite.
+5 -5
View File
@@ -62,8 +62,8 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
54 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, and admin
config. No automated integration tests against a live Electrum connection —
mainnet verification so far has been manual (see CLAUDE.md's "Project
status").
76 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
verification so far has been manual (see CLAUDE.md's "Project status").
-1
View File
@@ -251,7 +251,6 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
}
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
.toast.info { background: var(--color-surface-inset); color: var(--color-foreground); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* --- landing hero (shown only when logged out) --- */
+40 -15
View File
@@ -40,12 +40,13 @@ subito le nuove giocate, ma il round non chiude immediatamente. Se qualcuno
aveva già piazzato una bet negli ultimi istanti (transazione trasmessa ma
non ancora confermata), il round aspetta che anche quella si confermi prima
di procedere, così nessuna giocata già fatta viene persa al confine del
round. Solo a quel punto la card mostra un'animazione ("Estrazione del
vincitore in corso…") al posto del timer — la stessa cosa compare nella
dashboard di ogni giocatore, non solo la tua. L'animazione resta visibile
per almeno un tempo minimo configurabile dall'admin (default 20s), ma può
durare più a lungo, perché sotto la copertina servono **fino a tre
conferme sulla rete PLM in sequenza**, una diversa dall'altra:
round. Solo a quel punto la card mostra un messaggio di stato ("Round
chiuso — attesa conferma puntate…", poi "Estrazione in corso…", poi
"Pagamento al vincitore in corso…") al posto del timer — la stessa cosa
compare nella dashboard di **ogni** utente, anche di chi non ha giocato in
questo round. Questo messaggio resta visibile per l'intera durata della fase
(chiusura → estrazione → pagamento), perché sotto la copertina servono
**fino a tre conferme sulla rete PLM in sequenza**, una diversa dall'altra:
1. conferma dell'ultima giocata rimasta in sospeso (se ce n'era una proprio
allo scadere del timer — altrimenti questo passo è già superato);
@@ -59,14 +60,27 @@ c'era una giocata da confermare all'ultimo istante) o **2-4 minuti** (se
tutte le giocate erano già confermate prima dello zero) — non pochi
secondi, ed è normale.
Appena il vincitore è determinato, l'animazione lascia spazio a un
messaggio:
Se **hai giocato in questo round**, appena il vincitore è determinato compare
**in aggiunta** (non al posto del messaggio di stato sopra, che resta
visibile finché il pagamento non è confermato) un secondo riquadro solo per
te:
- **"🎉 Hai vinto! +N PLM"** se sei tu il vincitore — l'importo ti verrà
accreditato non appena la transazione di payout viene confermata (il
round successivo non si apre finché questo non accade)
- **"Non hai vinto questa volta."** altrimenti
Il messaggio resta visibile fino all'apertura del round successivo.
Chi non ha giocato in questo round non vede mai questo secondo riquadro,
solo il messaggio di stato generico. Il riquadro personale resta visibile
anche **dopo un refresh della pagina** (persiste nel browser), fino
all'apertura del round successivo — non serve restare sulla pagina per non
perderlo, e se hai perso completamente la finestra in tempo reale (es. tab in
background per diversi minuti), lo vedrai comunque comparire non appena
riapri la dashboard.
La dashboard si aggiorna anche **in tempo reale**, non solo a intervalli
fissi: appena qualcosa cambia sul server (una giocata, un cambio di fase del
round, un nuovo blocco confermato...) la pagina lo recepisce quasi subito,
senza bisogno di premere "Aggiorna" o ricaricare.
### Avviso di manutenzione
@@ -78,15 +92,22 @@ L'avviso sparisce da solo appena l'operatore riprende la lotteria.
### Deposito
- Il tuo **saldo interno** (accreditato dopo 1 conferma di rete) con bottone
"Aggiorna" per ricontrollarlo
- Il tuo **saldo interno**, con bottone "Aggiorna" per ricontrollarlo. Il
numero mostrato include anche il resto di una bet o un prelievo appena
inviati (non ancora confermato sulla rete) — non solo la parte già
confermata — così non sembra che il saldo sia crollato più del dovuto
subito dopo un'operazione. Il colore indica lo stato:
- **verde**: tutto confermato, il saldo mostrato è quello definitivo
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma —
il numero è corretto, ma non ancora "finale"
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
wallet scansionandolo invece di copiare l'indirizzo a mano
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
esterno. Il saldo si aggiorna da solo dopo la prima conferma; premi
"Aggiorna" per vederlo comparire.
esterno. Il saldo si aggiorna da solo dopo la prima conferma (e quasi subito,
grazie all'aggiornamento in tempo reale); premi "Aggiorna" se vuoi comunque
ricontrollarlo a mano.
### Bet
@@ -98,10 +119,14 @@ scalato dal tuo saldo interno.
Form con due campi:
- **Indirizzo esterno**: dove vuoi ricevere i PLM
- **Importo (PLM)**: quanto prelevare
- **Importo (PLM)**: quanto prelevare, oppure spunta **"Preleva l'intero
importo"** per prelevare tutto il saldo confermato senza doverlo
ricopiare a mano (il campo importo si disabilita e si aggiorna da solo)
Il prelievo viene costruito e trasmesso sulla rete; la fee di rete viene
scalata dall'importo richiesto (non si aggiunge separatamente).
scalata dall'importo richiesto (non si aggiunge separatamente). L'importo
minimo prelevabile è pari alla quota fissa di ingresso al round (mostrata
nella sezione Bet).
> **Nota**: attualmente è supportato solo l'indirizzo esterno in formato
> **P2WPKH bech32** (quelli che iniziano con `plm1q...`). Non inserire
+11
View File
@@ -60,6 +60,17 @@ docker compose stop # ferma senza rimuovere i container
docker compose down # ferma e rimuove i container (i dati in ./data/ restano)
```
> **Nota sul `Caddyfile`**: è montato in sola lettura nel container `caddy`
> (bind mount). Modificarlo non basta a farlo ripartire con la nuova
> configurazione — `docker compose up -d --build` non ricrea `caddy` solo
> perché il *contenuto* di un file montato è cambiato. Dopo una modifica al
> `Caddyfile` serve un passaggio in più:
> ```bash
> docker compose restart caddy
> ```
> (oppure, senza interrompere le connessioni esistenti: `docker compose exec
> caddy caddy reload --config /etc/caddy/Caddyfile`).
### ⚠️ Attenzione: riavvii automatici a metà round
`docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app:
+1 -1
View File
@@ -93,7 +93,7 @@ pip install -e ".[dev]"
## 6. Impostare l'indirizzo delle fee
Prima che il primo round possa pagare, un admin deve impostare `fee_address`
tramite il pannello admin o l'API — vedi [admin-guide.md](admin-guide.md). I
tramite il pannello admin o l'API — vedi [guida-admin.md](guida-admin.md). I
payout si rifiutano di partire finché non è impostato.
A questo punto l'istanza è pronta per essere avviata — continua con