Replace flowchart/ with a traceable specification in docs/

The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-28 12:16:01 +02:00
1 parent 526a649c8b
commit 61efbb0e37
29 files changed
+6964 -310

No files matched your search

+800
View File
@@ -0,0 +1,800 @@
# API HTTP
Specifica dell'interfaccia HTTP di PLM Lottery: convenzioni comuni, contratto
degli errori, ogni endpoint esposto e il catalogo completo dei codici di
errore. Descrive **la forma** delle richieste e delle risposte; il
comportamento di dominio che un endpoint innesca (come si costruisce una
scommessa, come si chiude un round…) è specificato nel documento del suo
argomento, a cui ogni sezione rimanda:
| Argomento | Documento |
|---|---|
| Registrazione, depositi, saldo mostrato | [registrazione-e-depositi.md](registrazione-e-depositi.md) |
| Scommesse e prelievi | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) |
| Round, estrazione, pagamento | [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) |
| Ciclo di vita delle transazioni, RBF | [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) |
| Chiavi, JWT, limiti di frequenza, header di sicurezza | [sicurezza.md](sicurezza.md) |
| Eventi del registro di audit | [audit-log.md](audit-log.md) |
| Tabelle e campi | [modello-dati.md](modello-dati.md) |
| Frontend, canale SSE lato architettura, i18n | [architettura.md](architettura.md) |
Le due interfacce web (`/` e `/admin`) parlano con questa stessa API JSON: non
esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
## Panoramica degli endpoint
| Metodo | Percorso | Autenticazione | Requisito |
|---|---|---|---|
| `POST` | `/auth/register` | nessuna | [API-18](#post-authregister) |
| `POST` | `/auth/login` | nessuna | [API-19](#post-authlogin) |
| `GET` | `/users/me` | Bearer | [API-20](#get-usersme) |
| `POST` | `/users/me/change-password` | Bearer | [API-21](#post-usersmechange-password) |
| `GET` | `/users/me/last-round-result` | Bearer | [API-22](#get-usersmelast-round-result) |
| `POST` | `/bets` | Bearer | [API-23](#post-bets) |
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) |
| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) |
| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) |
| `GET` | `/admin/config` | admin | [API-30](#get-adminconfig) |
| `PUT` | `/admin/config` | admin | [API-31](#put-adminconfig) |
| `POST` | `/admin/pause` | admin | [API-32](#post-adminpause-e-post-adminresume) |
| `POST` | `/admin/resume` | admin | [API-32](#post-adminpause-e-post-adminresume) |
| `GET` | `/admin/users` | admin | [API-33](#get-adminusers) |
| `GET` | `/admin/users/{user_id}/privkey` | admin | [API-34](#get-adminusersuser_idprivkey) |
| `POST` | `/admin/users/{user_id}/reset-password` | admin | [API-35](#post-adminusersuser_idreset-password) |
| `GET` | `/admin/rounds` | admin | [API-36](#get-adminrounds) |
| `GET` | `/admin/audit-log` | admin | [API-37](#get-adminaudit-log) |
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
| `GET` | `/health` | nessuna | [API-41](#get-health) |
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
## Convenzioni generali
- **[API-01]** L'API è servita dallo stesso processo e sulla stessa origine
delle interfacce web: in produzione `https://<SITE_ADDRESS>/…` attraverso
Caddy (che termina TLS e inoltra al container `app` sulla porta interna
8123), senza prefisso di versione (`/v1`…) e senza CORS — non è installato
alcun middleware CORS, quindi un browser può chiamarla solo dalla stessa
origine. I percorsi sono quelli della tabella sopra, alla radice.
*Codice:* `app/main.py` (`include_router`), `Caddyfile`, `Dockerfile`
- **[API-02]** Corpi di richiesta e di risposta sono JSON (`application/json`),
tranne tre eccezioni: `GET /qr/{address}` risponde `image/png`,
`GET /rounds/stream` risponde `text/event-stream`, e le pagine/i file statici
rispondono con il loro tipo. I campi dei corpi sono validati da modelli
Pydantic; i campi **non previsti vengono ignorati** in silenzio (comportamento
predefinito di Pydantic), non rifiutati.
*Codice:* `app/api/routes/` (modelli `*Request`/`*Response` di ogni file)
- **[API-03]** Tutti gli importi viaggiano come **interi in satoshi** (1 PLM =
100 000 000 sat), in campi il cui nome termina in `_sats`. L'API non espone
mai importi in PLM né valori decimali; la conversione per la visualizzazione
è compito del client.
*Codice:* `app/api/routes/` · `app/static/app.js:errorParams`
- **[API-04]** Ogni istante nelle risposte è una stringa ISO 8601 **in UTC con
offset esplicito** `+00:00` (ad esempio `2026-09-28T10:15:00.123456+00:00`).
SQLite restituisce datetime privi di fuso: prima della serializzazione vengono
marcati come UTC, così `new Date()` nel browser non li interpreta come ora
locale. `server_time` di `GET /rounds/current` è generato direttamente con
fuso UTC e ha lo stesso formato.
*Codice:* `app/api/timeutil.py:isoformat_utc` ·
*Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_users.py`
- **[API-05]** Esistono quattro modalità di autenticazione, fissate per
endpoint (colonna "Autenticazione" della panoramica):
| Modalità | Come si presenta | Se manca o è invalida |
|---|---|---|
| **Bearer** (`get_current_user`) | header `Authorization: Bearer <JWT>` | `401` |
| **facoltativa** (`get_optional_user`) | lo stesso header, se c'è | la richiesta prosegue come anonima |
| **admin** (`require_admin`) | header `X-Admin-Token: <ADMIN_TOKEN>` | `403` |
| **nessuna** | — | — |
Le regole di emissione e verifica del JWT (algoritmo, durata, claim `tv`,
revoca al cambio password) sono in [sicurezza.md](sicurezza.md); qui conta
cosa vede il client.
*Codice:* `app/auth/dependencies.py:get_current_user`,
`app/auth/dependencies.py:get_optional_user`,
`app/api/routes/admin.py:require_admin`
- **[API-06]** Autenticazione **Bearer**: il token è quello restituito da
`/auth/register`, `/auth/login` o `/users/me/change-password`. Un token
illeggibile, scaduto o con firma errata, un utente non più esistente e un
token *superato* (emesso prima dell'ultimo cambio o reset della password)
rispondono tutti `401` con codice `session_expired`. L'**assenza** dell'header
(o uno schema diverso da `Bearer`) è invece intercettata da FastAPI prima del
codice applicativo: `401` con il corpo non strutturato
`{"detail": "Not authenticated"}` e header `WWW-Authenticate: Bearer`.
*Codice:* `app/auth/dependencies.py:get_current_user` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
trattata come anonima — **mai** un errore. Serve agli endpoint raggiungibili
anche da non loggati che personalizzano la risposta se l'utente è noto
(`GET /rounds/current`, `POST /bug-reports`).
*Codice:* `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_rounds_route.py`, `tests/unit/test_bug_reports.py`
- **[API-08]** Autenticazione **admin**: l'header `X-Admin-Token` è confrontato
a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un
`500`) con `ADMIN_TOKEN` del `.env`. Un `ADMIN_TOKEN` vuoto nega **tutto**,
anche un header vuoto. Il rifiuto è `403` con il corpo non strutturato
`{"detail": "invalid admin token"}`. Non c'è identità per singolo operatore
(vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
- **[API-09]** Intestazioni di cache:
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
`Cache-Control: no-store`;
- il flusso SSE risponde con `Cache-Control: no-store` e
`X-Accel-Buffering: no`;
- il QR risponde con `Cache-Control: private, max-age=86400, immutable`;
- le risposte JSON e i file statici serviti dal mount (`/app.js`,
`/style.css`, `/i18n.js`, `/index.html`…) **non** hanno `Cache-Control`:
i file statici portano solo `ETag`/`Last-Modified` per la validazione
condizionale.
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
`app/main.py:user_guide`, `app/main.py:report_bug_page`,
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`
- **[API-10]** La documentazione automatica di FastAPI (`/docs`, `/redoc`,
`/openapi.json`) è **disattivata** a meno che `ENABLE_API_DOCS` non sia
impostato a vero nel `.env`: elencherebbe a chiunque l'intera superficie
dell'API, endpoint admin compresi. Disattivata, quei percorsi rispondono
`404`.
*Codice:* `app/main.py` (costruttore `FastAPI`), `app/config.py:Settings` ·
*Test:* `tests/unit/test_docs_disabled.py`
- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti
di login e registrazione, tetto per IP del flusso SSE), l'IP è l'**ultimo**
elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua
assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di
Caddy sono in [sicurezza.md](sicurezza.md).
*Codice:* `app/api/client_ip.py:client_ip` ·
*Test:* `tests/unit/test_client_ip.py`
## Contratto degli errori
- **[API-12]** Ogni errore **voluto** dal codice applicativo risponde con un
`detail` strutturato:
```json
{
"detail": {
"code": "insufficient_balance",
"message": "insufficient balance",
"params": {"required_sats": 1000000000}
}
}
```
- `code` — identificatore stabile, in `snake_case`; è ciò che il client usa.
- `message` — testo **in inglese** per client che non conoscono i codici
(curl, test); l'API è monolingua per scelta.
- `params` — i valori che il messaggio interpola (importi come `*_sats`,
limiti, attese), sempre presente, eventualmente `{}`.
Gli errori nascono come `ApiError` (o sue sottoclassi `BetError`,
`WithdrawalError`) nei servizi, oppure direttamente con `http_error` nelle
route.
*Codice:* `app/api/errors.py:ApiError`, `app/api/errors.py:http_error`,
`app/api/errors.py:from_api_error`
- **[API-13]** Gli errori di `POST /bets` e `POST /withdrawals` sollevati dal
servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`:
è la rete ad aver rifiutato la transazione, non il chiamante.
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_bets.py`
- **[API-14]** Qualunque eccezione non gestita risponde `500` con lo stesso
formato strutturato, codice `internal_error`, messaggio
`"internal server error"`, `params` vuoto. Il testo e lo stack
dell'eccezione finiscono solo nel log (`logs/app.log`), **mai** nella
risposta.
*Codice:* `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py`
- **[API-15]** Un corpo, un parametro di percorso o di query che non supera la
validazione risponde `422` nel formato **nativo** di FastAPI: `detail` è una
**lista** di oggetti `{type, loc, msg, input, ctx?}`, in inglese, uno per
campo invalido (anche il JSON malformato arriva così, con
`type: "json_invalid"`). Non c'è un `code`: il frontend riconosce la lista e
mostra il messaggio generico tradotto `error.invalid_request`.
*Codice:* FastAPI (nessun gestore personalizzato),
`app/static/app.js:apiErrorMessage` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`,
`tests/unit/test_bug_reports.py`
- **[API-16]** Alcune risposte d'errore **non** seguono il formato strutturato
e hanno `detail` come semplice stringa inglese:
| Stato | `detail` | Dove |
|---|---|---|
| `401` | `"Not authenticated"` | header `Authorization` assente su un endpoint Bearer (FastAPI) |
| `403` | `"invalid admin token"` | ogni endpoint `/admin/*` |
| `404` | `"user not found"` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` |
| `404` | `"bug report not found"` | `POST /admin/bug-reports/{id}/status` |
| `404` | `"Not Found"` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) |
| `405` | `"Method Not Allowed"` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router |
| `503` | `"too many concurrent update streams"` | `GET /rounds/stream` oltre il tetto globale |
Il frontend mostra la stringa così com'è.
*Codice:* `app/api/routes/admin.py:require_admin`,
`app/api/routes/admin.py:user_privkey`,
`app/api/routes/admin.py:reset_user_password`,
`app/api/routes/admin.py:update_bug_report_status`,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
ogni parametro numerico `<nome>_sats` un gemello `<nome>_plm` già formattato
in PLM; se la chiave non esiste mostra `message`. Ogni codice che l'API può
restituire deve avere la sua chiave `error.<code>` in **tutte e sette** le
lingue (en, it, es, fr, de, ru, zh). Un `401` con codice `session_expired`
riporta inoltre il frontend alla schermata di login.
*Codice:* `app/static/app.js:apiErrorMessage`,
`app/static/app.js:errorParams`, `app/static/i18n.js:TRANSLATIONS`
## Endpoint
### Autenticazione
#### POST /auth/register
- **[API-18]** Crea un account e restituisce subito una sessione.
- **Autenticazione:** nessuna.
- **Corpo:**
| Campo | Tipo | Vincoli |
|---|---|---|
| `username` | stringa | 3–32 caratteri, solo `A-Z a-z 0-9 _ . -` |
| `password` | stringa | 8–256 caratteri (`MIN_PASSWORD_LENGTH` = 8) |
- **Risposta `201`:** `{"access_token": <JWT>, "address": <indirizzo plm1…>}` —
l'indirizzo di deposito permanente dell'utente.
- **Errori:** `422` (validazione del corpo); `429 rate_limited` (quota IP
esaurita, `params.retry_after_seconds`); `409 username_taken` (nome già
usato, **senza distinzione tra maiuscole e minuscole**, anche se
l'altro account si è registrato nello stesso istante);
`409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile
assegnare un indice di derivazione libero).
- **Limite di frequenza:** al massimo 5 account creati per IP in una finestra
mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e
consumata solo da una registrazione riuscita (un nome già preso o un
errore di validazione non la consumano). Dettagli in
[sicurezza.md](sicurezza.md).
- **Effetti:** inserisce l'utente con il nome così come digitato, il
prossimo indice di derivazione e l'indirizzo derivato; avvia (senza
attenderla, *best effort*) la sottoscrizione Electrum dell'indirizzo. Non
scrive audit e non notifica l'SSE. Il flusso completo è in
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
#### POST /auth/login
- **[API-19]** Apre una sessione.
- **Autenticazione:** nessuna.
- **Corpo:** `{"username": stringa, "password": stringa}`, senza vincoli di
lunghezza. Lo username è confrontato senza distinzione tra maiuscole e
minuscole.
- **Risposta `200`:** `{"access_token": <JWT>, "address": <indirizzo>}`.
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`,
arrotondato per eccesso al secondo); `401 invalid_credentials` —
**identico** per username inesistente e password sbagliata.
- **Limite di frequenza:** due contatori di fallimenti con attesa
esponenziale, uno per username (dal 5° fallimento) e uno per IP (dal 20°).
Il blocco è verificato prima della password, quindi durante un blocco
anche la password giusta riceve `429`. Un login riuscito azzera solo il
contatore dello username. Valori e motivazioni in
[sicurezza.md](sicurezza.md).
- **Effetti:** nessuno oltre ai contatori in memoria; nessun audit.
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
### Utente
#### GET /users/me
- **[API-20]** Profilo e saldo dell'utente autenticato.
- **Autenticazione:** Bearer.
- **Risposta `200`:**
| Campo | Tipo | Significato |
|---|---|---|
| `id` | intero | identificativo dell'utente |
| `username` | stringa | come digitato alla registrazione |
| `address` | stringa | indirizzo permanente (deposito, vincite, resto) |
| `balance_sats` | intero | saldo **confermato e spendibile** (`User.cached_balance_sats`); è quello su cui si basano scommesse e prelievi |
| `pending_balance_sats` | intero | saldo **comprensivo** del resto ancora non confermato di scommesse/prelievi in volo — il valore da mostrare; è un totale, non una differenza |
| `has_pending` | booleano | vero se esiste almeno una transazione in volo dell'utente (il frontend mostra il saldo in ambra) |
| `created_at` | stringa ISO | data di registrazione |
Come si calcolano i due saldi è in
[registrazione-e-depositi.md](registrazione-e-depositi.md).
- **Errori:** `401` (vedi [API-06](#convenzioni-generali)).
- **Effetti:** nessuno.
*Codice:* `app/api/routes/users.py:me`,
`app/wallet/balance.py:compute_pending_balance` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_balance.py`
#### POST /users/me/change-password
- **[API-21]** Cambio password self-service.
- **Autenticazione:** Bearer.
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`.
- **Risposta `200`:** `{"access_token": <JWT>}` — un **nuovo** token per la
sessione corrente.
- **Errori**, nell'ordine in cui sono verificati: `401` di autenticazione;
`401 current_password_incorrect`; `400 password_too_short`
(`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi
`400`, non `422` come alla registrazione) e **non** c'è una lunghezza
massima.
- **Effetti:** sostituisce l'hash e incrementa `User.token_version`: ogni
token emesso prima, compreso quello usato per questa richiesta, da quel
momento riceve `401 session_expired`. Non scrive audit e non notifica
l'SSE. Nessun limite di frequenza.
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
#### GET /users/me/last-round-result
- **[API-22]** Esito dell'ultimo round **chiuso** a cui l'utente ha
partecipato, letto dal database. È la rete di sicurezza per un dispositivo
che ha perso la finestra in cui `GET /rounds/current` mostrava il vincitore.
- **Autenticazione:** Bearer.
- **Risposta `200`:**
| Campo | Tipo | Significato |
|---|---|---|
| `round_id` | intero o `null` | l'ultimo round in stato `closed` con una riga di partecipazione dell'utente; `null` se non ce n'è nessuno |
| `won` | booleano | vero se l'utente è il vincitore di quel round |
| `amount_sats` | intero o `null` | importo effettivamente pagato al vincitore (`Round.winner_amount_sats`, già al netto della commissione del pagamento), solo se `won` |
Senza round chiusi giocati la risposta è
`{"round_id": null, "won": false, "amount_sats": null}`.
- **Errori:** `401`.
- **Effetti:** nessuno.
*Codice:* `app/api/routes/users.py:last_round_result`
### Scommesse e prelievi
#### POST /bets
- **[API-23]** Piazza la scommessa dell'utente sul round in corso, al costo
fisso attuale. Regole, sequenza e rollback sono in
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:** nessuno.
- **Risposta `201`:**
| Campo | Tipo | Significato |
|---|---|---|
| `round_id` | intero | round a cui la scommessa è iscritta |
| `bet_txid` | stringa | txid della transazione trasmessa |
| `bet_amount_sats` | intero | importo arrivato al montepremi, cioè il costo **meno** la commissione di rete |
| `status` | stringa | stato del partecipante, `"broadcast"` |
- **Errori:** `503 network_unavailable` (nessuna connessione Electrum,
verificato **prima** di ogni altro controllo); poi, tutti `400` salvo
indicazione: `no_round_open`, `round_closing`, `already_betting`,
`round_full` (`params.max_participants`), `insufficient_balance`
(`params.required_sats`), `too_many_inputs` (`params.max_inputs`),
`balance_leaves_no_change` (`params.required_extra_sats`),
`amount_below_network_fee`, `amount_below_dust_limit`,
`502 broadcast_failed`. Il significato di ciascuno è nel
[catalogo](#catalogo-dei-codici-di-errore).
- **Serializzazione:** la richiesta tiene il blocco per-utente condiviso con
`POST /withdrawals` per tutta la costruzione e la trasmissione.
- **Effetti:** può **aprire un round** se nessuno è attivo e le condizioni
lo permettono; segna le monete spese, crea partecipante e transazione in
sospeso, trasmette. Audit `bet_placed` in caso di successo,
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in entrambi i casi
(e all'apertura del round).
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
#### POST /withdrawals
- **[API-24]** Invia fondi a un indirizzo esterno. Regole e sequenza in
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:**
| Campo | Tipo | Vincoli |
|---|---|---|
| `external_address` | stringa | verificato nel servizio (bech32 valido con prefisso `plm1`) |
| `amount_sats` | intero | nessun vincolo di schema; il minimo è verificato nel servizio |
- **Risposta `201`:**
| Campo | Tipo | Significato |
|---|---|---|
| `txid` | stringa | txid della transazione trasmessa |
| `amount_requested_sats` | intero | importo chiesto |
| `amount_sent_sats` | intero | importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto |
| `status` | stringa | stato del prelievo, `"broadcast"` |
- **Errori:** `422`; `503 network_unavailable` (verificato **prima** della
validazione dell'indirizzo); poi `400`: `invalid_address`,
`withdrawal_to_own_address`, `amount_below_minimum`
(`params.minimum_sats` = costo attuale della scommessa),
`balance_pending_confirmation` (`params.pending_sats`),
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
(`params.max_inputs`), `amount_below_network_fee`,
`amount_below_dust_limit`; `502 broadcast_failed`.
- **Serializzazione:** stesso blocco per-utente di `POST /bets`.
- **Effetti:** segna le monete spese, crea prelievo e transazione in
sospeso, trasmette. Audit `withdrawal_sent`, oppure
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`;
notifica SSE in entrambi i casi.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
### Round
#### GET /rounds/current
- **[API-25]** Stato del round attivo, pensato per essere interrogato a ciclo
dalle due interfacce (anche la pillola Electrum/altezza di `/admin` legge
`chain_tip_height` da qui).
- **Autenticazione:** facoltativa (serve solo per `user_played`).
- **Risposta `200`** — tutti i campi sono sempre presenti:
| Campo | Tipo | Significato |
|---|---|---|
| `server_time` | stringa ISO | ora del server, per allineare i conti alla rovescia del client |
| `round_id` | intero o `null` | round attivo; `null` se non ce n'è |
| `status` | stringa o `null` | `open`, `closing`, `drawing` o `paying_out`; un round `closed` non compare mai |
| `opened_at` | stringa ISO o `null` | apertura del round |
| `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) |
| `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae |
| `bet_amount_sats` | intero | costo della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) |
| `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre |
| `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) |
| `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo |
| `has_pending_bets` | booleano | vero se `pending_participant_count` > `participant_count` |
| `draw_animation_seconds` | intero | durata minima dell'animazione dell'estrazione, dalla configurazione |
| `winner_user_id` | intero o `null` | vincitore, valorizzato dall'estrazione in poi (`paying_out`) |
| `winner_amount_sats` | intero o `null` | importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita |
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco (il seme dell'estrazione) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota |
| `lottery_paused` | booleano | pausa di manutenzione attiva |
| `lottery_configured` | booleano | falso finché manca configurazione indispensabile (oggi: `fee_address`), e quindi nessun round può aprirsi |
| `user_played` | booleano | vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo |
Senza round attivo sono valorizzati solo `server_time`, `bet_amount_sats`,
`draw_animation_seconds`, `chain_tip_height`, `lottery_paused` e
`lottery_configured`; gli altri valgono `null`, `0` o `false`. Il
significato di dominio di ogni fase è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
- **Errori:** nessuno previsto (un token invalido non è un errore,
[API-07](#convenzioni-generali)).
- **Effetti:** **non** apre round (a differenza di `POST /bets`); crea la
riga di configurazione con i valori predefiniti se non esiste ancora.
*Codice:* `app/api/routes/rounds.py:current_round`,
`app/rounds/service.py:get_active_round`,
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
`app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_route.py`
#### GET /rounds/stream
- **[API-26]** Canale Server-Sent Events: una notifica senza contenuto
("qualcosa è cambiato, rileggi") ogni volta che cambia lo stato di round,
scommesse o saldi. È **aggiuntivo** al polling, che resta attivo; i punti del
codice che notificano e il ruolo del canale sono in
[architettura.md](architettura.md).
- **Autenticazione:** nessuna; la notifica non contiene dati.
- **Risposta `200`**, `text/event-stream`, con `Cache-Control: no-store` e
`X-Accel-Buffering: no`. Messaggi:
- `event: update` / `data: {}` — a ogni notifica; notifiche arrivate prima
che il client abbia letto la precedente si fondono in una sola (coda di
capacità 1 per client);
- `: keep-alive` (commento) — dopo 20 secondi senza notifiche.
- **Chiusura:** la connessione di un client scomparso senza chiusura pulita
è rilevata entro 5 secondi. Oltre **5** flussi contemporanei dallo stesso
IP, l'apertura di un nuovo flusso **chiude il più vecchio** di quell'IP.
- **Errori:** `503` con `{"detail": "too many concurrent update streams"}`
(non strutturato) quando i flussi aperti in totale sono già **500**; il
client `EventSource` riprova da solo e intanto resta il polling.
- **Effetti:** nessuno.
*Codice:* `app/api/routes/rounds.py:round_stream`,
`app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/rounds/events.py:RoundEventBroadcaster.publish` ·
*Test:* `tests/unit/test_round_events.py`
### QR
#### GET /qr/{address}
- **[API-27]** Immagine PNG del QR code di un indirizzo PLM, usata dal pannello
Deposito con un semplice `<img>`.
- **Autenticazione:** nessuna (un `<img>` non può portare il token).
- **Parametro di percorso:** `address`, al massimo 100 caratteri, bech32
valido con prefisso `plm1` (stessa verifica dei prelievi).
- **Risposta `200`:** `image/png`, con
`Cache-Control: private, max-age=86400, immutable`.
- **Errori:** `400 invalid_address`.
- **Effetti:** nessuno. Il disegno avviene fuori dal ciclo di eventi ed è
memorizzato per indirizzo (ultimi 512 indirizzi), così una richiesta
ripetuta costa solo una lettura in memoria.
*Codice:* `app/api/routes/qr.py:address_qr`,
`app/api/routes/qr.py:_render_png`,
`app/wallet/address.py:is_valid_plm_address` ·
*Test:* `tests/unit/test_qr.py`
### Segnalazioni di bug
#### POST /bug-reports
- **[API-28]** Invia una segnalazione, da loggati o no.
- **Autenticazione:** facoltativa; se il chiamante è autenticato la
segnalazione gli viene attribuita.
- **Corpo:**
| Campo | Tipo | Vincoli |
|---|---|---|
| `description` | stringa | 1–2000 caratteri (lunghezza misurata sul testo ricevuto); spazi iniziali e finali rimossi; non può essere vuota dopo la rimozione |
| `contact` | stringa o `null` | facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa `null` |
- **Risposta `201`:** `{"id": intero}`.
- **Errori:** `422`.
- **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit,
nessuna notifica SSE, nessun limite di frequenza.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/api/routes/bug_reports.py:BugReportCreate` ·
*Test:* `tests/unit/test_bug_reports.py`
#### GET /bug-reports/mine
- **[API-29]** Le segnalazioni inviate dall'utente mentre era autenticato, per
seguirne lo stato. Quelle anonime non vi compaiono mai.
- **Autenticazione:** Bearer.
- **Risposta `200`:** lista, dalla più recente, di
`{"id", "description", "status", "created_at"}`; `status` è `open`,
`read` o `resolved`. Il campo `contact` non è restituito.
- **Errori:** `401`.
*Codice:* `app/api/routes/bug_reports.py:list_my_bug_reports` ·
*Test:* `tests/unit/test_bug_reports.py`
### Amministrazione
Tutti gli endpoint `/admin/*` richiedono `X-Admin-Token`
([API-08](#convenzioni-generali)) e rispondono `403` senza di esso. Gli eventi
di audit citati sono descritti in [audit-log.md](audit-log.md); il significato
dei parametri operativi in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
[modello-dati.md](modello-dati.md).
#### GET /admin/config
- **[API-30]** Legge la configurazione operativa (la riga unica di
`round_config`, creata con i valori predefiniti se manca).
- **Risposta `200`:** `{"fee_address", "bet_amount_sats",
"round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb",
"rbf_timeout_seconds", "draw_animation_seconds", "paused"}`.
*Codice:* `app/api/routes/admin.py:read_config` ·
*Test:* `tests/unit/test_admin.py`
#### PUT /admin/config
- **[API-31]** Modifica a caldo uno o più parametri operativi.
- **Corpo:** tutti i campi facoltativi; quelli assenti o `null` non
cambiano.
| Campo | Vincoli |
|---|---|
| `fee_address` | spazi rimossi; bech32 PLM valido (`plm1…`). Non può essere svuotato tramite l'API |
| `bet_amount_sats` | > 0 e ≤ 10 000 000 000 000 (100 000 PLM) |
| `round_duration_seconds` | 30 – 604 800 (7 giorni) |
| `round_cooldown_seconds` | 0 – 86 400 |
| `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) |
| `rbf_timeout_seconds` | 60 – 604 800 |
| `draw_animation_seconds` | 0 – 600 |
`paused` **non** è accettato qui (è ignorato come campo sconosciuto): la
pausa ha i suoi endpoint registrati nell'audit.
- **Risposta `200`:** la configurazione completa, come
[API-30](#get-adminconfig).
- **Errori:** `422` per qualunque vincolo violato (nessun campo viene
applicato).
- **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata e pausa
tra round si applicano dal round successivo. Nessuna notifica SSE.
*Codice:* `app/api/routes/admin.py:update_config`,
`app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
#### POST /admin/pause e POST /admin/resume
- **[API-32]** Attivano e disattivano la pausa di manutenzione: impedisce
l'apertura del **prossimo** round, il round in corso prosegue fino al
pagamento.
- **Corpo:** nessuno. **Risposta `200`:** la configurazione completa.
- **Effetti:** impostano `paused` a vero/falso e scrivono `lottery_paused` /
`lottery_resumed` a **ogni** chiamata, anche se lo stato era già quello.
Nessuna notifica SSE: `/` vede `lottery_paused` al polling successivo.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/users
- **[API-33]** Elenco di **tutti** gli utenti, per `id` crescente, senza
paginazione né limite.
- **Risposta `200`:** lista di `{"id", "username", "address",
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
*Codice:* `app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/users/{user_id}/privkey
- **[API-34]** Esporta la chiave privata WIF dell'indirizzo di un utente, per
interventi manuali. È una scelta voluta (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
- **Parametro:** `user_id` intero.
- **Risposta `200`:** `{"address", "wif"}`.
- **Errori:** `404` `"user not found"` (non strutturato); `422` se `user_id`
non è un intero.
- **Effetti:** scrive `admin_privkey_accessed` a ogni accesso.
*Codice:* `app/api/routes/admin.py:user_privkey` ·
*Test:* `tests/unit/test_admin.py`
#### POST /admin/users/{user_id}/reset-password
- **[API-35]** Imposta una nuova password casuale per un utente bloccato.
- **Risposta `200`:** `{"username", "new_password"}` — la password (16
caratteri URL-safe) è mostrata **solo** in questa risposta.
- **Errori:** `404` `"user not found"`; `422`.
- **Effetti:** sostituisce l'hash, incrementa `token_version` (ogni sessione
aperta dell'utente riceve `401 session_expired`), scrive
`admin_password_reset`.
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/rounds
- **[API-36]** Storico dei round, dal più recente.
- **Query:** `limit` intero 1–500, predefinito 50 (fuori intervallo: `422`).
- **Risposta `200`:** lista di `{"id", "status", "opened_at", "closed_at",
"draw_block_height", "draw_block_hash", "winner_user_id",
"winner_username", "pool_amount_sats", "winner_amount_sats",
"fee_amount_sats", "payout_txid"}`; i campi non ancora valorizzati sono
`null`.
*Codice:* `app/api/routes/admin.py:list_rounds` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/audit-log
- **[API-37]** Registro di audit, dalla voce più recente.
- **Query:** `limit` intero 1–500, predefinito 200.
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
"user_id", "round_id", "created_at"}`.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/pending-transactions
- **[API-38]** Transazioni tracciate (scommesse, pagamenti, prelievi), dalla
più recente.
- **Query:** `limit` intero 1–500, predefinito 50; `status` facoltativo,
confrontato alla lettera (un valore inesistente restituisce una lista
vuota, non un errore).
- **Risposta `200`:** lista di `{"id", "kind", "status", "round_id",
"withdrawal_id", "user_id", "current_txid", "fee_rate_sat_vb",
"attempt_count", "broadcast_at", "replaced_by_txid"}`. Non sono esposti
`failure_reason`, `last_broadcast_at` né la transazione grezza. Stati e
significato in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/api/routes/admin.py:list_pending_transactions` ·
*Test:* `tests/unit/test_admin.py`
#### GET /admin/bug-reports
- **[API-39]** Tutte le segnalazioni, dalla più recente.
- **Query:** `limit` intero 1–500, predefinito 200.
- **Risposta `200`:** lista di `{"id", "description", "contact",
"user_id", "username", "status", "created_at"}`; `user_id` e `username`
sono `null` per le segnalazioni anonime.
*Codice:* `app/api/routes/admin.py:list_bug_reports` ·
*Test:* `tests/unit/test_bug_reports.py`
#### POST /admin/bug-reports/{report_id}/status
- **[API-40]** Cambia lo stato di una segnalazione.
- **Corpo:** `{"status": "open" | "read" | "resolved"}`; qualunque
transizione è ammessa, anche all'indietro.
- **Risposta `200`:** la segnalazione aggiornata, come
[API-39](#get-adminbug-reports).
- **Errori:** `422` (stato sconosciuto); `404` `"bug report not found"`.
- **Effetti:** scrive `bug_report_status_changed` (con stato precedente e
nuovo) solo se lo stato cambia davvero.
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_bug_reports.py`
### Servizio
#### GET /health
- **[API-41]** Risponde sempre `200 {"status": "ok"}` finché il processo
serve richieste. Non verifica né il database né la connessione Electrum:
per lo stato della rete si usa `chain_tip_height` di `GET /rounds/current`.
*Codice:* `app/main.py:health`
#### Pagine e file statici
- **[API-42]** `GET /`, `/admin`, `/guida` e `/report-bug` restituiscono
rispettivamente `index.html`, `admin.html`, `guida.html` e
`report-bug.html` di `app/static/`, con `Cache-Control: no-store`, senza
autenticazione (il pannello admin chiede il token lato client). Sono esclusi
dallo schema OpenAPI.
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
`app/main.py:user_guide`, `app/main.py:report_bug_page`
- **[API-43]** Ogni altro percorso non gestito da un router è servito dal
mount dei file statici su `/` (`app/static/`, con `html=True`): i file
esistenti rispondono `200`, gli altri `404 {"detail": "Not Found"}`, un
metodo diverso da `GET`/`HEAD` `405`. Il mount è registrato **dopo** tutti i
router, quindi non può oscurare un endpoint dell'API.
*Codice:* `app/main.py` (`app.mount`)
## Catalogo dei codici di errore
- **[API-44]** L'insieme dei codici che l'API può restituire nel formato
strutturato è esattamente quello della tabella seguente; un codice nuovo va
aggiunto qui, con la sua chiave `error.<code>` in tutte e sette le lingue di
`app/static/i18n.js`.
*Codice:* `app/api/errors.py:ApiError`, `app/static/i18n.js:TRANSLATIONS`
| Codice | Stato | Sollevato da | `params` | Significato | `error.<code>` (7 lingue) |
|---|---|---|---|---|---|
| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì |
| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì |
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login` | `retry_after_seconds` | quota di registrazione o blocco per tentativi di login falliti | sì |
| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì |
| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì |
| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì |
| `password_too_short` | 400 | `app/api/routes/users.py:change_password` | `minimum` | nuova password sotto `MIN_PASSWORD_LENGTH` | sì |
| `network_unavailable` | 503 | `app/api/routes/bets.py:create_bet`, `app/api/routes/withdrawals.py:create_withdrawal` | — | nessuna connessione Electrum attiva | sì |
| `no_round_open` | 400 | `app/bets/service.py:place_bet` | — | nessun round attivo e nessuno apribile (pausa, `fee_address` mancante, pausa tra round in corso) | sì |
| `round_closing` | 400 | `app/bets/service.py:place_bet` | — | il round attivo non accetta più scommesse (scadenza passata o stato diverso da `open`), anche se scoperto dopo la costruzione | sì |
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
| `amount_below_dust_limit` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | al netto della commissione l'importo sarebbe polvere | sì |
| `broadcast_failed` | 502 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | — (il motivo del rifiuto è nel `message`) | la rete ha rifiutato la trasmissione; tutto è stato annullato | sì |
| `invalid_address` | 400 | `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/qr.py:address_qr` | — | non è un indirizzo bech32 PLM valido | sì |
| `withdrawal_to_own_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale all'indirizzo di deposito dell'utente | sì |
| `amount_below_minimum` | 400 | `app/withdrawals/service.py:request_withdrawal` | `minimum_sats` | prelievo sotto il costo attuale della scommessa | sì |
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee`
e `commission_below_dust_limit` — riguardano solo la costruzione del
pagamento, che avviene nello scheduler e non attraverso l'API: non arrivano mai
a un client (finiscono nel registro di audit, vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)) e non hanno
traduzione.
Chiavi `error.*` presenti in `i18n.js` che il server **non** produce: `invalid_request`
(usata dal frontend per le risposte `422`, [API-15](#contratto-degli-errori)),
`invalid_amount` (validazione lato client del modulo di prelievo) e
`guide_unavailable` (attualmente non usata da nessun file).
+798
View File
@@ -0,0 +1,798 @@
# Architettura
Come è fatto il software dall'interno: quali processi girano, come è
organizzato il codice, cosa succede all'avvio, quali attività lavorano in
background, dove sta la configurazione, come le pagine web restano
aggiornate e come è strutturata la suite di test.
Questo documento non descrive i flussi di dominio (scommesse, round,
depositi…): per quelli rimanda ai documenti che li possiedono, elencati in
[come-funziona.md](come-funziona.md). Le tabelle del database sono in
[modello-dati.md](modello-dati.md), gli endpoint in [api.md](api.md), le
difese di sicurezza in [sicurezza.md](sicurezza.md).
## Panoramica del deployment
```mermaid
flowchart LR
B["Browser<br/>(/, /admin, /report-bug)"] -- "HTTPS 443<br/>(80 → redirect)" --> C["Container caddy<br/>reverse proxy + TLS"]
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
A -- "scrive" --> L["./data/logs/app.log"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
- **[ARC-01]** Il server gira sempre in Docker, con lo stesso
`docker-compose.yml` in sviluppo e in produzione: due container, `app`
(questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo
`SITE_ADDRESS`: non impostato vale `localhost` (certificato autofirmato
dalla CA interna di Caddy), impostato a un dominio produce un certificato
Let's Encrypt. Non esiste un modo supportato di lanciare `uvicorn` fuori da
Docker.
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
`--workers`), in ascolto su `0.0.0.0:8123`. Tutto lo stato in memoria
descritto in [Assunzioni di processo singolo](#assunzioni-di-processo-singolo)
dipende da questo.
*Codice:* `Dockerfile` (`CMD`)
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
`exec`. Le migrazioni non si lanciano mai a mano.
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
443. Su questo si basa la lettura dell'IP del client (vedi
[sicurezza.md](sicurezza.md)). Caddy non comprime `/rounds/stream`, perché
la compressione accumulerebbe gli eventi SSE invece di consegnarli subito.
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`
- **[ARC-05]** I dati persistenti stanno in `./data/` sull'host, montata nel
container: `./data/db` → `/app/db_data`, `./data/keys` → `/app/key_data`,
`./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e
`MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili
arrivano da `.env` (`env_file`). Entrambi i container hanno
`restart: unless-stopped`.
*Codice:* `docker-compose.yml`
### Database
- **[ARC-06]** Il database è SQLite, usato tramite SQLAlchemy 2 in modalità
asincrona (`create_async_engine` su `aiosqlite`). Una sola `engine` e una
sola fabbrica di sessioni (`AsyncSessionLocal`, con
`expire_on_commit=False`) servono sia le richieste HTTP (dipendenza
`get_session`) sia le attività in background, che aprono ciascuna le
proprie sessioni.
*Codice:* `app/db/base.py` · `app/db/session.py:get_session`
- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati
`journal_mode=WAL`, `synchronous=NORMAL` e `busy_timeout=5000` ms: in WAL
lettori e scrittori non si bloccano a vicenda, e uno scrittore che trova il
database occupato aspetta fino a 5 secondi invece di fallire subito. Con un
dialetto diverso da SQLite le pragma non vengono applicate.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
tramite migrazioni Alembic in `migrations/versions/` (generate con
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
database da `settings.database_url`. I campi delle tabelle sono descritti
in [modello-dati.md](modello-dati.md).
*Codice:* `migrations/env.py` · `app/db/models.py`
### Log
- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`,
`uvicorn.access`) finiscono in `logs/app.log` (nel container
`/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da
10 MB con 5 file di storico, livello `INFO`. La configurazione avviene
all'import di `app/main.py`, prima di qualunque altro modulo applicativo.
*Codice:* `app/logging_config.py:setup_logging`
## Mappa del codice
Ogni file sotto `app/`, raggruppato per pacchetto. I file `__init__.py`
sono vuoti e non sono elencati.
### Radice e infrastruttura
| File | Responsabilità |
|---|---|
| `app/main.py` | Punto d'ingresso: `lifespan` (validazione segreti, avvio e arresto dei sei task), inclusione dei router, gestore degli errori non previsti, pagine HTML, `/health`, mount dei file statici |
| `app/config.py` | `Settings` (variabili d'ambiente / `.env`), istanza `settings`, `validate_runtime_secrets`, `ConfigError` |
| `app/logging_config.py` | `setup_logging`: file di log a rotazione |
| `app/db/base.py` | `engine`, `AsyncSessionLocal`, pragma SQLite, `Base` dichiarativa |
| `app/db/session.py` | `get_session`, dipendenza FastAPI che apre una sessione per richiesta |
| `app/db/models.py` | Tutte le tabelle (`User`, `UtxoEvent`, `Round`, `RoundParticipant`, `RoundConfig`, `PendingTransaction`, `Withdrawal`, `BugReport`, `AuditLog`) e l'indice del round attivo unico — vedi [modello-dati.md](modello-dati.md) |
| `app/audit/log.py` | `write_audit_log`: aggiunge una riga al registro di audit nella sessione del chiamante (il commit resta al chiamante) — catalogo eventi in [audit-log.md](audit-log.md) |
### API HTTP
| File | Responsabilità |
|---|---|
| `app/api/errors.py` | `ApiError` (codice + messaggio inglese + parametri), `http_error`, `from_api_error`: il formato strutturato di `detail` — vedi [api.md](api.md) |
| `app/api/client_ip.py` | `client_ip`: IP reale del chiamante (ultimo elemento di `X-Forwarded-For`), usato da tutti i controlli basati sull'IP |
| `app/api/timeutil.py` | `isoformat_utc`: serializza le date come UTC esplicito (SQLite le restituisce senza fuso) |
| `app/api/routes/admin.py` | `/admin/*`: `require_admin` (token condiviso), configurazione, pausa/ripresa, utenti, export WIF, reset password, round, transazioni pendenti, audit log, bug report |
| `app/api/routes/bets.py` | `POST /bets`: controllo connessione, lock per utente, chiamata a `place_bet` |
| `app/api/routes/withdrawals.py` | `POST /withdrawals`: stessa struttura, chiama `request_withdrawal` |
| `app/api/routes/rounds.py` | `GET /rounds/current` e il canale SSE `GET /rounds/stream` |
| `app/api/routes/users.py` | `GET /users/me`, cambio password, `GET /users/me/last-round-result` |
| `app/api/routes/qr.py` | `GET /qr/{address}`: PNG del QR, indirizzo validato, rendering memorizzato e fuori dall'event loop |
| `app/api/routes/bug_reports.py` | `POST /bug-reports`, `GET /bug-reports/mine` |
### Autenticazione
| File | Responsabilità |
|---|---|
| `app/auth/routes.py` | `/auth/register`, `/auth/login`; notifica al listener il nuovo indirizzo da osservare |
| `app/auth/security.py` | Argon2 (versioni sincrone e `*_async` su threadpool), creazione e decodifica JWT, `MIN_PASSWORD_LENGTH` |
| `app/auth/dependencies.py` | `get_current_user`, `get_optional_user` (controllo del claim `tv`) |
| `app/auth/rate_limit.py` | `RateLimiter`, `RollingQuota`, `AuthRateLimiters` (tenuti su `app.state`) — vedi [sicurezza.md](sicurezza.md) |
### Wallet e chiavi
| File | Responsabilità |
|---|---|
| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` |
| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv |
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo |
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS` |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints` |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md) |
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
### Dominio
| File | Responsabilità |
|---|---|
| `app/deposits/service.py` | Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino |
| `app/deposits/reconcile.py` | `DepositReconciler`: ricontrollo periodico di tutti gli indirizzi |
| `app/bets/service.py` | `place_bet`, `BetError`, rilascio di una scommessa fallita |
| `app/bets/confirmation.py` | Gestore di conferma per il tipo `bet` |
| `app/withdrawals/service.py` | `request_withdrawal`, `WithdrawalError`, rilascio di un prelievo fallito |
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
Flussi descritti in [scommesse-e-prelievi.md](scommesse-e-prelievi.md),
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
[registrazione-e-depositi.md](registrazione-e-depositi.md).
### Transazioni
| File | Responsabilità |
|---|---|
| `app/tx/confirmation.py` | `ConfirmationPoller`, `poll_once`, registro dei gestori (`register_handler`) |
| `app/tx/broadcast.py` | `RbfBumper`, `should_bump`, `bump_fee` |
| `app/tx/reconcile.py` | `PendingTransactionReconciler`, `reconcile_once`: promozione o abbandono delle righe `building`/`pending` |
| `app/tx/pending_address.py` | `own_address_for`: indirizzo proprietario degli input di una transazione (utente o pool) |
| `app/tx/locks.py` | `UserLocks`: lock asincrono per utente |
Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
### Frontend statici
| File | Responsabilità |
|---|---|
| `app/static/index.html` | Pagina `/` per i giocatori |
| `app/static/app.js` | Logica di `/`: autenticazione, pannelli, polling, SSE, rivelazione del risultato |
| `app/static/style.css` | Stile di `/` (e di `/report-bug`) |
| `app/static/i18n.js` | Tabella `TRANSLATIONS` in 7 lingue, `t()`, `tOrNull()`, `setLanguage()`, `applyStaticTranslations()` |
| `app/static/report-bug.html` | Pagina `/report-bug`, con il proprio script inline |
| `app/static/admin.html` | Pannello `/admin` |
| `app/static/admin.js` | Logica del pannello: token, sezioni, polling dello stato catena, SSE |
| `app/static/admin.css` | Stile del pannello |
| `app/static/guida.html` | Pagina `/guida`, per ora un segnaposto |
### Dipendenze tra pacchetti
Il grafo riporta gli import tra i pacchetti di `app/` (a livello di pacchetto,
non di singolo modulo). `db` e `config` sono importati quasi ovunque e sono
omessi dalle frecce per leggibilità.
```mermaid
flowchart TD
main["main"] --> routes["api.routes"]
main --> auth
main --> tx
main --> rounds
main --> electrum
main --> deposits
main --> bets
main --> withdrawals
routes --> auth
routes --> bets
routes --> withdrawals
routes --> rounds
routes --> wallet
routes --> audit
routes --> apiutil["api (errors, client_ip, timeutil)"]
auth --> apiutil
auth --> wallet
bets --> apiutil
bets --> rounds
bets --> wallet
bets --> audit
bets -- "register_handler" --> tx
withdrawals --> apiutil
withdrawals --> rounds
withdrawals --> wallet
withdrawals --> audit
withdrawals -- "register_handler" --> tx
rounds --> electrum
rounds --> wallet
rounds --> audit
rounds -- "register_handler" --> tx
electrum --> deposits
electrum -- "draw (header math), events" --> rounds
deposits --> electrum
deposits --> wallet
deposits --> audit
deposits -- "events" --> rounds
tx --> wallet
tx --> electrum
tx --> audit
tx -- "events, config" --> rounds
```
- **[ARC-10]** Il poller delle conferme (`app/tx/confirmation.py`) non importa
i moduli di dominio: sono `app/bets/confirmation.py`,
`app/rounds/confirmation.py` e `app/withdrawals/confirmation.py` a
registrarsi con `register_handler(kind, handler)` al momento dell'import,
per i tipi `bet`, `payout` e `withdrawal`.
*Codice:* `app/tx/confirmation.py:register_handler` ·
`app/bets/confirmation.py` · `app/rounds/confirmation.py` ·
`app/withdrawals/confirmation.py` ·
*Test:* `tests/unit/test_confirmation.py`
- **[ARC-11]** Gli errori di dominio destinati all'utente sono sottoclassi di
`ApiError` (`BetError`, `WithdrawalError`) sollevate direttamente dai
servizi, con il codice che il frontend traduce; le route li convertono in
`HTTPException` scegliendo lo stato HTTP. Per questo `bets` e
`withdrawals` dipendono da `app/api/errors.py`.
*Codice:* `app/api/errors.py:ApiError` · `app/bets/service.py:BetError` ·
`app/withdrawals/service.py:WithdrawalError`
## Avvio del processo
`app/main.py` fa due cose distinte: **all'import** costruisce l'applicazione
FastAPI; **all'avvio del server** (il `lifespan`) prepara lo stato condiviso
e lancia le attività in background.
```mermaid
sequenceDiagram
participant U as uvicorn
participant M as app/main.py (import)
participant L as lifespan
U->>M: import app.main
M->>M: setup_logging()
M->>M: import dei gestori di conferma (bet, payout, withdrawal)
M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
U->>L: startup
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste
U->>L: shutdown
L->>L: cancel() dei 6 task, chiusura del client Electrum
```
### All'import
- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa
di `app/main.py`, prima di importare qualunque altro modulo `app.*`, così
anche i log emessi durante gli import finiscono nel file.
*Codice:* `app/main.py` · `app/logging_config.py:setup_logging`
- **[ARC-13]** `app/main.py` importa `app.bets.confirmation`,
`app.rounds.confirmation` e `app.withdrawals.confirmation` **solo per il
loro effetto collaterale** (la registrazione del gestore, vedi
[ARC-10]). Questi import non vanno rimossi: senza gestore, il poller
segnerebbe comunque la `PendingTransaction` come `confirmed`, ma senza
aggiornare il partecipante, il round o il prelievo corrispondente.
*Codice:* `app/main.py` · `app/tx/confirmation.py:poll_once`
- **[ARC-14]** La documentazione automatica (`/docs`, `/redoc`,
`/openapi.json`) è disattivata a meno che `ENABLE_API_DOCS` sia vero.
*Codice:* `app/main.py` · *Test:* `tests/unit/test_docs_disabled.py`
- **[ARC-15]** I router vengono inclusi in quest'ordine: `auth`, `users`,
`bets`, `withdrawals`, `admin`, `bug_reports`, `qr`, `rounds`. Gli endpoint
sono in [api.md](api.md).
*Codice:* `app/main.py`
- **[ARC-16]** Qualunque eccezione non gestita produce una risposta 500 con
lo stesso formato degli errori voluti
(`{"detail": {"code": "internal_error", "message": "internal server error", "params": {}}}`);
il dettaglio dell'eccezione va solo nel log, mai nella risposta.
*Codice:* `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py`
- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare
database né rete.
*Codice:* `app/main.py:health`
- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il
file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` →
`admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`.
Per ultimo, l'intera cartella `app/static/` è montata su `/` con
`StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…). I percorsi sono relativi
alla directory di lavoro, che nel container è `/app`.
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
`app/main.py:user_guide` · `app/main.py:report_bug_page`
### Nel lifespan
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32
caratteri o `XPRV_ENCRYPTION_KEY` è vuota, riportando tutti i problemi
insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello resta
semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore
di `Settings`, così import e test non hanno bisogno di segreti veri.
*Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py`
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`; un
elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene
scritto nel log.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
alla prima richiesta di registrazione o login che ne ha bisogno.
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento
ciascun task riceve `cancel()` (senza attenderne la fine) e il client
Electrum, se presente, viene chiuso.
*Codice:* `app/main.py:lifespan` · *Test:* `tests/unit/test_code_comments.py`
## Attività in background
| Task | Costruito con | Cadenza | Ruolo |
|---|---|---|---|
| `ElectrumListener` | `_make_electrum_client`, `AsyncSessionLocal`, endpoint | ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi |
| `RoundScheduler` | `AsyncSessionLocal`, `listener` | 5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti |
| `ConfirmationPoller` | `AsyncSessionLocal`, `lambda: listener.client` | 10s | `pending` → `confirmed` e gestore per tipo |
| `RbfBumper` | `AsyncSessionLocal`, `lambda: listener.client` | 30s | Aumenta la commissione di ciò che è `pending` oltre `rbf_timeout_seconds` |
| `PendingTransactionReconciler` | `AsyncSessionLocal`, `lambda: listener.client` | subito, poi 120s | Promuove o abbandona le righe `building`/`pending` |
| `DepositReconciler` | `AsyncSessionLocal`, `listener` | 300s, attende prima del primo giro | `refresh_user` su ogni indirizzo |
- **[ARC-23]** **Accesso al client Electrum.** Nessun task tiene un
riferimento fisso al client: `ConfirmationPoller`, `RbfBumper` e
`PendingTransactionReconciler` ricevono `lambda: listener.client` e lo
rileggono a ogni giro; `RoundScheduler` e `DepositReconciler` ricevono il
listener intero e leggono `listener.client` (il secondo anche per chiamare
`refresh_user`). Così una riconnessione sostituisce il client sotto tutti.
Un client non nullo implica che l'altezza della catena sia già nota (vedi
[connessione-electrum.md](connessione-electrum.md)).
*Codice:* `app/main.py:lifespan`
- **[ARC-24]** **Client assente.** Quando `listener.client` è `None` ogni task
salta il giro e riprova alla cadenza successiva; `DepositReconciler`
interrompe anche una scansione in corso. Le route che devono trasmettere
(`POST /bets`, `POST /withdrawals`) rispondono invece `503`
`network_unavailable`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/tx/confirmation.py:ConfirmationPoller.run` ·
`app/tx/broadcast.py:RbfBumper.run` ·
`app/tx/reconcile.py:PendingTransactionReconciler.run` ·
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
- **[ARC-25]** **Errori.** Ogni ciclo intercetta qualunque `Exception` di un
giro, la scrive nel log con lo stack e riprende alla cadenza successiva;
solo `CancelledError` (lo spegnimento) esce dal ciclo. All'interno di un
giro, poller, bumper, riconciliatore e `DepositReconciler` isolano anche il
singolo elemento: una transazione o un utente che fallisce non ferma gli
altri. Non esiste un supervisore: un task che terminasse non verrebbe
riavviato (l'unico caso previsto è il listener senza alcun endpoint, che
scrive un errore ed esce; `parse_endpoints` include però sempre il server
primario).
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run` ·
`app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:RbfBumper._tick` ·
`app/tx/reconcile.py:reconcile_once` ·
`app/deposits/reconcile.py:DepositReconciler.run` ·
`app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_reconcile.py` ·
`tests/unit/test_deposit_reconcile.py`
- **[ARC-26]** **`ElectrumListener`.** Dopo ogni sessione terminata passa
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
scatta solo quando tutti i server hanno fallito nello stesso giro.
Rotazione, keepalive, validazione e corroborazione sono in
[connessione-electrum.md](connessione-electrum.md).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo. L'estrazione
(`_close_and_draw`, con l'attesa del blocco successivo) viene eseguita
**dentro** il giro: finché dura, lo scheduler non fa altro. Dopo un riavvio
un round rimasto in `drawing` non viene ripreso (limite accettato, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)). Logica del round in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions`; il riconciliatore parte
subito perché un crash durante un invio lascia proprio una riga `building`
da risolvere al riavvio. Regole in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/tx/confirmation.py:ConfirmationPoller` ·
`app/tx/broadcast.py:RbfBumper` ·
`app/tx/reconcile.py:PendingTransactionReconciler` ·
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_broadcast.py` ·
`tests/unit/test_reconcile.py`
- **[ARC-29]** **`DepositReconciler`** attende 300s, poi scorre tutti gli
utenti e chiama `listener.refresh_user` su ciascuno: lo stesso codice usato
dalle notifiche, così i due percorsi non possono divergere.
*Codice:* `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
## Configurazione
La configurazione è divisa in due posti con regole diverse.
- **[ARC-30]** **Segreti e collegamenti infrastrutturali** stanno nelle
variabili d'ambiente (file `.env`, letto da `Settings`; variabili sconosciute
ignorate). `settings` è costruito una volta all'import di `app/config.py`:
una modifica richiede il riavvio del container. I campi sono:
| Campo (`Settings`) | Variabile | Predefinito | Uso |
|---|---|---|---|
| `database_url` | `DATABASE_URL` | `sqlite+aiosqlite:///./plm_lottery.db` | URL SQLAlchemy (in Docker sovrascritto dal compose) |
| `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario |
| `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario |
| `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario |
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls]` separati da virgola |
| `xprv_encryption_key` | `XPRV_ENCRYPTION_KEY` | `""` | Chiave Fernet della master xprv (obbligatoria, [ARC-19]) |
| `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) |
| `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) |
| `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT |
| `jwt_expire_minutes` | `JWT_EXPIRE_MINUTES` | `1440` (24h) | Durata del token |
| `admin_token` | `ADMIN_TOKEN` | `""` | Token del pannello `/admin`; vuoto = pannello chiuso |
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF,
indirizzo commissioni, pausa di manutenzione) stanno nell'unica riga della
tabella `round_config`, modificabile a caldo da `/admin` senza riavvio. I
valori iniziali sono i default delle colonne di `RoundConfig`, non
`app/config.py`. Se la riga manca, viene creata al primo accesso con
`fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md), effetti
in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/config.py:get_round_config` ·
`app/db/models.py:RoundConfig`
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste (1), i limiti di input e di
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
## Aggiornamenti in tempo reale (SSE)
Le pagine si aggiornano da sole in due modi sovrapposti: il polling
periodico e, in più, un canale **Server-Sent Events** che le avvisa appena
qualcosa cambia.
```mermaid
flowchart LR
subgraph Processo
P1["round aperto / cambio di stato"] --> BR
P2["scommessa o prelievo inviato / annullato"] --> BR
P3["transazione confermata / abbandonata"] --> BR
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
P5["nuovo header"] --> BR
BR["broadcaster.publish()"] --> Q1["coda client 1<br/>(maxsize 1)"]
BR --> Q2["coda client N"]
end
Q1 -- "event: update" --> S1["EventSource /"]
Q2 -- "event: update" --> S2["EventSource /admin"]
S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"]
```
- **[ARC-33]** Il canale è pub/sub **in-process**: `broadcaster` è un unico
`RoundEventBroadcaster` per processo, con una `asyncio.Queue(maxsize=1)`
per client. `publish()` mette una notifica in ogni coda non piena; se la
coda è già piena la nuova notifica viene scartata, perché quella in attesa
basta già a far ricaricare tutto (notifiche ridondanti si fondono).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-34]** Il messaggio non ha contenuto e lo stream non ha
autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni
dato, compresa la personalizzazione (come `user_played`), arriva dagli
endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato
di ogni evento, anche di quelli che non lo riguardano.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-35]** Limiti di sottoscrizione: al massimo `MAX_SUBSCRIBERS` (500)
stream in tutto, oltre i quali `GET /rounds/stream` risponde `503` e il
browser ricade sul polling; e al massimo `MAX_SUBSCRIBERS_PER_IP` (5) per
IP client, oltre i quali la connessione **più vecchia** di quello stesso IP
viene chiusa (le si invia il segnale `EVICTED`) invece di rifiutare la
nuova. L'IP è quello di `client_ip` (vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` ·
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-36]** Lo stream invia `event: update` con `data: {}` per ogni
notifica. Da fermo controlla `request.is_disconnected()` ogni 5 secondi e
invia un commento `: keep-alive` ogni 20 secondi (4 controlli), così né un
client sparito senza chiudere né il timeout di inattività di un proxy lo
lasciano appeso. La risposta porta `Cache-Control: no-store` e
`X-Accel-Buffering: no`. Alla chiusura il client viene sempre rimosso.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-37]** `publish()` viene chiamato in questi punti, e ogni nuovo
cambiamento di stato visibile deve aggiungersi a questo elenco:
- apertura di un round — `app/rounds/service.py:open_new_round_if_needed`;
- ogni transizione di stato del round (`closing`, `closed` senza
partecipanti, `drawing`, `paying_out`) —
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`;
- scommessa inviata e scommessa annullata —
`app/bets/service.py:place_bet`, `app/bets/service.py:_release_failed_bet`;
- prelievo inviato e prelievo annullato —
`app/withdrawals/service.py:request_withdrawal`,
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`;
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
`app/deposits/service.py:reinstate_reappeared_utxos`;
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
## Lock per utente
- **[ARC-38]** `UserLocks` tiene un `asyncio.Lock` per `user_id` (creato alla
prima richiesta di quell'utente e mai rimosso). `POST /bets` e
`POST /withdrawals` eseguono l'intera costruzione e trasmissione dentro
`app.state.user_locks.acquire(user.id)`: per lo stesso utente una scommessa
e un prelievo non sono mai in costruzione insieme, perché spendono dallo
stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi;
cosa implica è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md). Il
controllo "client connesso" avviene prima di acquisire il lock.
*Codice:* `app/tx/locks.py:UserLocks.acquire` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal`
## Frontend
- **[ARC-39]** Le interfacce sono pagine statiche in `app/static/`, servite
direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano,
senza passo di build, framework né bundler. Parlano tutte con la stessa API
JSON; l'unica separazione tra utente e amministratore è `require_admin` sul
lato server.
*Codice:* `app/main.py` · `app/static/`
### `/` — giocatori
- **[ARC-40]** **Polling.** Da non autenticati, la pagina interroga
`GET /rounds/current` ogni 15s solo per la barra di stato della catena. Da
autenticati: `refreshRound` ogni 15s, ogni 3s mentre il round è in
`closing`/`drawing`/`paying_out`; `checkLastRoundResult`
(`GET /users/me/last-round-result`) ogni 20s; il conto alla rovescia
locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano
subito (i timer delle schede in background vengono rallentati dal
browser).
*Codice:* `app/static/app.js:startChainOnlyPolling` ·
`app/static/app.js:scheduleNextRoundPoll` ·
`app/static/app.js:showDashboard`
- **[ARC-41]** **Richieste.** Ogni chiamata API di `/` ha un timeout di 15s;
dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione
persa". Una risposta `401` con codice `session_expired` riporta al login.
*Codice:* `app/static/app.js:call` · `app/static/app.js:noteFetchOutcome`
- **[ARC-42]** **SSE.** La pagina apre un solo `EventSource('/rounds/stream')`
e reagisce sia agli eventi `update` sia a `open` (che scatta alla prima
connessione e a ogni riconnessione automatica): da autenticati ricarica
round, profilo e ultimo risultato, altrimenti solo lo stato della catena.
Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina
funziona come prima, solo senza l'avviso immediato.
*Codice:* `app/static/app.js:connectRoundEvents` ·
`app/static/app.js:onRoundServerEvent`
- **[ARC-43]** **Chiavi di `localStorage`** usate da `/`:
| Chiave | Contenuto |
|---|---|
| `plm_token` | JWT della sessione |
| `plm_username` | Nome utente mostrato |
| `plm_address` | Indirizzo di deposito |
| `plm_persisted_result` | Risultato del round rivelato (`round_id`, `won`, `amount_sats`), per ridisegnarlo dopo un ricaricamento |
| `plm_lang` | Lingua scelta (condivisa con `/report-bug`) |
Il logout svuota `localStorage` tranne `plm_lang`. La rimozione di
`plm_token` in una scheda riporta al login anche le altre schede aperte
(evento `storage`). La logica della rivelazione del risultato è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/static/app.js:logout` · `app/static/app.js:persistResult` ·
`app/static/i18n.js:setLanguage`
### `/admin` — operatore
- **[ARC-44]** Il pannello chiede il token operatore, lo verifica con
`GET /admin/config` e lo conserva in `sessionStorage` (`plm_admin_token`,
quindi si perde chiudendo la scheda); ogni chiamata lo invia
nell'intestazione `X-Admin-Token`. Non è collegato da `/` né lo collega.
*Codice:* `app/static/admin.js:callAdmin`
- **[ARC-45]** Il pannello interroga `GET /rounds/current` ogni 15s per la
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
aperte; non hanno un polling periodico proprio. La sezione Parametri non
viene ricaricata dagli eventi.
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
### `/report-bug` e `/guida`
- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza
login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e
per mostrare i propri report (`GET /bug-reports/mine`). Non usa SSE né
polling. Il flusso di triage è descritto in [api.md](api.md) e in
[guida-admin.md](guida-admin.md).
*Codice:* `app/static/report-bug.html`
- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida
completa sarà pubblicata qui a breve").
*Codice:* `app/static/guida.html`
## Internazionalizzazione
Riguarda `/` e `/report-bug`. `/admin` e `/guida` sono volutamente solo in
italiano.
- **[ARC-48]** Le lingue supportate sono sette: `en`, `it`, `es`, `fr`, `de`,
`ru`, `zh`. Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in
`app/static/i18n.js`, caricato prima dello script della pagina; non ci sono
file separati né richieste di rete.
*Codice:* `app/static/i18n.js`
- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di
`localStorage.plm_lang` se supportato, le prime due lettere di
`navigator.language` se supportate, altrimenti `en`. `setLanguage` salva la
scelta in `plm_lang`, ritraduce la pagina e chiama `onLanguageChange` se la
pagina la definisce. Su `/` il selettore sta nella barra di stato della
catena, non nella barra di navigazione, perché quest'ultima è nascosta fino
al login.
*Codice:* `app/static/i18n.js:detectDefaultLang` ·
`app/static/i18n.js:setLanguage` · `app/static/index.html`
- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n`
(testo), `data-i18n-html`, `data-i18n-placeholder`, `data-i18n-title`,
`data-i18n-aria-label`, `data-i18n-alt`, applicati da
`applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che
nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da
`onLanguageChange`. Un elemento usa **uno solo** dei due meccanismi, mai
entrambi (per esempio `#bet-btn` non ha `data-i18n`, perché la sua
etichetta contiene il costo configurabile).
*Codice:* `app/static/i18n.js:applyStaticTranslations` ·
`app/static/app.js:onLanguageChange` ·
`app/static/app.js:renderBetButton`
- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi**.
`t()` cerca la chiave nella lingua corrente, poi in `en`, e se manca anche
lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null`,
così il chiamante può usare un testo di riserva. I parametri si
sostituiscono nei segnaposto `{nome}`.
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull`
- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail`
strutturato `{code, message, params}` (vedi [api.md](api.md)); la
traduzione spetta al client. Per un `detail` oggetto il frontend mostra
`error.<code>` nella lingua corrente, con i parametri; a ogni parametro
`*_sats` numerico affianca un `*_plm` già formattato; se il codice non ha
traduzione mostra `message`. Un `detail` stringa viene mostrato così com'è;
un `detail` lista (errore di validazione 422 di FastAPI) diventa
`error.invalid_request`. Aggiungere un errore rivolto all'utente significa
dargli un codice e aggiungere `error.<codice>` in tutte e sette le lingue.
*Codice:* `app/static/app.js:apiErrorMessage` ·
`app/static/app.js:errorParams` · `app/api/errors.py:ApiError`
- **[ARC-53]** Su `/report-bug` l'avviso `bugReport.englishNotice` è tradotto
in tutte le lingue, ma dice sempre di scrivere la segnalazione in inglese,
perché il pannello di triage non è tradotto.
*Codice:* `app/static/report-bug.html` · `app/static/i18n.js`
## Test
- **[ARC-54]** La suite è tutta in `tests/unit/` (351 test raccolti);
`tests/integration/` contiene solo `__init__.py`. Si esegue con
`python -m pytest` dal venv. `pyproject.toml` imposta `testpaths = ["tests"]`
e `asyncio_mode = "auto"`, quindi i test asincroni non richiedono
`@pytest.mark.asyncio`. Dipendenze di sviluppo: `pytest`, `pytest-asyncio`,
`httpx`.
*Codice:* `pyproject.toml` · *Test:* `tests/unit/test_docs_current.py`
- **[ARC-55]** Non esiste un `conftest.py`: ogni file definisce le proprie
fixture. Lo schema tipico di una fixture `session_factory` è: master key
generata in una directory temporanea con una chiave Fernet casuale (e
cache `hd._account_key` azzerata), engine SQLite in memoria con
`Base.metadata.create_all` (le migrazioni Alembic non vengono eseguite),
una riga `RoundConfig` con un `fee_address` valido dove serve aprire round.
I test HTTP costruiscono un'app FastAPI minima con i soli router
necessari, un `ElectrumListener` senza connessione su `app.state`, e la
interrogano con `httpx.AsyncClient` su `ASGITransport`: il `lifespan` di
`app/main.py` non viene eseguito.
*Test:* `tests/unit/test_bets.py` · `tests/unit/test_rounds_route.py`
- **[ARC-56]** La rete non viene mai contattata: al posto del client e del
listener Electrum ogni file usa classi finte locali, ad esempio
`FakeElectrumClient` (`test_bets.py`, `test_withdrawals.py`,
`test_balance.py`), `RejectingElectrumClient` (`test_bets.py`),
`FakeClient` e `ExplodingClient` (`test_confirmation.py`), `FakeClient`
(`test_broadcast.py`), `UnknownTxClient`, `KnownTxClient`, `BrokenClient`
(`test_reconcile.py`), `FakeListener`, `FakePayoutClient`,
`FakePayoutListener`, `CorroboratingListener`, `LateTipListener`,
`StallingListener` (`test_scheduler.py`), `FakeListener`
(`test_deposit_reconcile.py`), `FakeWriter` (`test_electrum_client.py`).
*Test:* i file citati
- **[ARC-57]** Alcuni test verificano file che non sono codice Python,
perché nessun altro test li eserciterebbe:
- `test_docs_current.py`: il numero di test dichiarato in `CLAUDE.md` e
`README.md` coincide con quello raccolto; `CLAUDE.md` non contiene
affermazioni superate note; la mappa del codice di `CLAUDE.md` cita
`rate_limit.py`, `client_ip.py` e `bug_reports`; i link relativi del
README e le ancore verso `CLAUDE.md` esistono;
- `test_docs_no_bare_uvicorn.py`: README e `docs/running-the-server.md`
non documentano un `uvicorn --reload` fuori da Docker;
- `test_code_comments.py`: alcuni commenti nel codice restano veri (per
esempio il numero di task avviati dal lifespan, sei, citato in
`app/db/base.py`);
- `test_caddyfile_security_headers.py`: il `Caddyfile` mantiene le
intestazioni di sicurezza e la sovrascrittura di `X-Forwarded-For`;
- `test_withdrawals.py::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive`:
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) descrive correttamente
la convivenza di scommessa e prelievo.
Chi modifica questi file (compresi i documenti in `docs/`) deve tenerne
conto.
*Test:* `tests/unit/test_docs_current.py` ·
`tests/unit/test_docs_no_bare_uvicorn.py` ·
`tests/unit/test_code_comments.py` ·
`tests/unit/test_caddyfile_security_headers.py` ·
`tests/unit/test_withdrawals.py`
- **[ARC-58]** Ciò che la suite **non** copre: nessun test contro una
connessione Electrum reale; il `lifespan` e l'avvio dei sei task; la route
SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua
acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei
frontend; la parità delle chiavi tra le lingue di `i18n.js`. Le verifiche
dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni
reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
## Assunzioni di processo singolo
- **[ARC-59]** Diversi componenti tengono stato nella memoria del processo e
funzionano correttamente solo con un unico processo uvicorn ([ARC-02]):
- il canale SSE (`broadcaster`): un evento pubblicato in un processo non
raggiungerebbe i client collegati a un altro;
- i lock per utente (`UserLocks`): due processi potrebbero costruire
insieme una scommessa e un prelievo dello stesso utente;
- i limitatori di login e registrazione (`AuthRateLimiters` su
`app.state`): ogni processo conterebbe separatamente;
- l'`ElectrumListener` e i sei task: ogni processo aprirebbe la propria
connessione e il proprio scheduler, con due scheduler che avanzano lo
stesso round;
- le cache locali (chiave d'account decifrata in `app/wallet/hd.py`, QR
memorizzati in `app/api/routes/qr.py`), che sarebbero solo duplicate.
Un deployment multi-processo richiederebbe un canale condiviso (per
esempio Redis pub/sub) e un lock su database o Redis. Fa eccezione
l'invariante "al massimo un round attivo", garantita da un indice del
database e quindi valida anche con più processi (vedi
[modello-dati.md](modello-dati.md)). La scelta è discussa in
[decisioni-e-limiti.md](decisioni-e-limiti.md).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
`app/tx/locks.py:UserLocks` · `app/auth/rate_limit.py:AuthRateLimiters` ·
`app/main.py:lifespan`
+443
View File
@@ -0,0 +1,443 @@
# Registro di audit
Il registro di audit è la traccia, nel database, di ciò che ha mosso denaro o
cambiato la configurazione: scommesse, prelievi, depositi accreditati,
estrazioni, pagamenti, interventi dell'operatore. È il primo posto da guardare
per ricostruire cosa è successo dopo un problema, ed è l'unica forma di
responsabilità che esiste per le azioni dal pannello admin, che non ha
un'identità per singolo operatore (vedi [sicurezza.md](sicurezza.md)).
Questo documento descrive come il registro viene scritto e letto, e cataloga
ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
[modello-dati.md](modello-dati.md); le procedure che partono da un evento
(per esempio un `payout_failed`) sono in [operativita.md](operativita.md).
## Come si scrive
- **[AUD-01]** Ogni riga di audit nasce da un'unica funzione,
`write_audit_log(session, event_type, payload, user_id=None, round_id=None)`.
La funzione **non fa commit**: aggiunge la riga alla sessione del chiamante,
che la salva insieme al resto delle sue modifiche. Nessun altro punto del
codice crea righe `AuditLog`.
*Codice:* `app/audit/log.py:write_audit_log`
- **[AUD-02]** Per gli eventi che accompagnano un cambiamento di stato, la riga
di audit è salvata **nella stessa transazione** del cambiamento che descrive:
o si salvano entrambi, o nessuno dei due. Vale per tutti gli eventi del
catalogo tranne quelli elencati in [AUD-03].
*Codice:* i chiamanti elencati nel catalogo, ognuno dei quali chiama
`write_audit_log` e poi `session.commit()` sulla stessa sessione.
- **[AUD-03]** Quattro eventi sono **solo osservativi**: non accompagnano un
cambiamento di stato e vengono salvati in una transazione propria, aperta
apposta. Sono `draw_baseline_tip_unknown`, `draw_header_corroboration_failed`,
`draw_stalled` e `payout_failed`. Per `payout_failed` la scrittura è anche
*best effort*: se fallisce, l'errore viene registrato nel log applicativo
(`could not record the payout failure of round …`) e ignorato.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`,
`app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip`,
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure`
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
`payload_json`; `created_at` è impostato dal database all'inserimento, in
UTC. `user_id` e `round_id` sono chiavi esterne opzionali, valorizzate come
indicato evento per evento nel catalogo.
*Codice:* `app/audit/log.py:write_audit_log`, `app/db/models.py:AuditLog`
- **[AUD-05]** Il registro è **in sola aggiunta**: nessuna parte del codice
modifica o cancella una riga di `audit_log`, e non c'è alcuna rotazione o
scadenza. La tabella cresce per sempre.
*Codice:* `app/audit/log.py:write_audit_log` (unico punto di scrittura)
- **[AUD-06]** `event_type` è una colonna `String(32)`. Il nome più lungo oggi
in uso, `draw_header_corroboration_failed`, occupa esattamente 32 caratteri:
un nuovo tipo di evento deve restare entro questo limite. SQLite non lo fa
rispettare, un altro database lo farebbe.
*Codice:* `app/db/models.py:AuditLog`
## Come si legge
- **[AUD-07]** `GET /admin/audit-log` (protetto da `X-Admin-Token`) restituisce
le righe dalla più recente (ordine per `id` decrescente), con il parametro
`limit` (predefinito 200, minimo 1, massimo 500). Ogni elemento contiene
`id`, `event_type`, `payload` (già decodificato da JSON), `user_id`,
`round_id` e `created_at` in ISO 8601 UTC. **Non esistono filtri** per tipo
di evento, utente o round, né paginazione: le righe più vecchie delle ultime
500 si leggono solo direttamente dal database.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
sezione, e mostra id, tipo, payload JSON grezzo, utente, round e data. Non ha
filtri né ricerca.
*Codice:* `app/static/admin.js:loadAuditLog`
- **[AUD-09]** Una sola logica del programma rilegge il registro:
`_retry_payout_if_due` usa la riga `payout_failed` più recente del round come
orologio per decidere se ritentare il pagamento. Se è più recente di 60
secondi (`_PAYOUT_RETRY_INTERVAL_SECONDS`) il tentativo viene saltato; se non
esiste, il tentativo parte subito. Nessun'altra decisione dipende dal
registro.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
*Test:* `tests/unit/test_scheduler.py`
## Catalogo degli eventi
Legenda delle colonne `user_id` / `round_id`: **sì** = sempre valorizzato,
**no** = sempre vuoto, **dipende** = come spiegato nel testo. Gli importi sono
sempre in satoshi.
```mermaid
flowchart LR
subgraph Operatore["Pannello admin"]
config_updated
lottery_paused
lottery_resumed
admin_privkey_accessed
admin_password_reset
bug_report_status_changed
end
subgraph Fondi["Denaro degli utenti"]
deposit_credited
utxo_spent_externally
utxo_external_spend_reinstated
bet_placed
bet_broadcast_failed
withdrawal_sent
withdrawal_broadcast_failed
end
subgraph Round["Round"]
round_closed
winner_drawn
draw_baseline_tip_unknown
draw_header_corroboration_failed
draw_stalled
payout_failed
payout_sent
end
subgraph Ricon["Riconciliazione"]
pending_tx_recovered
pending_tx_abandoned
end
```
### Pannello admin
- **[AUD-10]** `config_updated` — un `PUT /admin/config` ha cambiato almeno un
parametro. Payload: un oggetto con una chiave per ogni campo **effettivamente
cambiato**, ciascuna `{"from": <valore precedente>, "to": <valore nuovo>}`
(i campi sono quelli di `_CONFIG_FIELDS`: `fee_address`, `bet_amount_sats`,
`round_duration_seconds`, `round_cooldown_seconds`, `fee_rate_sat_vb`,
`rbf_timeout_seconds`, `draw_animation_seconds`). Un campo inviato con lo
stesso valore che aveva non compare; se nulla cambia, non si scrive nessuna
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
*Test:* `tests/unit/test_admin.py`
- **[AUD-11]** `lottery_paused` — `POST /admin/pause`. Payload vuoto `{}`.
`user_id` no, `round_id` no. Viene scritto a ogni chiamata, anche se la
lotteria era già in pausa. Stessa transazione di `paused = true`.
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload vuoto `{}`.
`user_id` no, `round_id` no. Scritto a ogni chiamata, anche se la lotteria
non era in pausa. Stessa transazione di `paused = false`.
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
`user_id` sì (l'utente la cui chiave è stata esportata), `round_id` no. La
riga è salvata **prima** che la risposta con la chiave venga restituita: se
il salvataggio fallisce, la chiave non esce. Per un utente inesistente
(404) non si scrive nulla. Questa registrazione non va mai rimossa (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/api/routes/admin.py:user_privkey` ·
*Test:* nessun test verifica la riga di audit (`tests/unit/test_admin.py`
copre solo l'esportazione).
- **[AUD-14]** `admin_password_reset` — `POST /admin/users/{id}/reset-password`
ha impostato una nuova password casuale. Payload `{"user_id"}`; la nuova
password **non** compare. `user_id` sì, `round_id` no. Stessa transazione del
nuovo hash e dell'incremento di `token_version` (che invalida le sessioni
esistenti).
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-15]** `bug_report_status_changed` — `POST /admin/bug-reports/{id}/status`
ha cambiato lo stato di una segnalazione. Payload
`{"report_id", "from", "to"}` con gli stati `open`/`read`/`resolved`.
`user_id` dipende: è l'autore della segnalazione, vuoto se era anonima;
`round_id` no. Se lo stato richiesto è quello già presente non si scrive
nulla. Stessa transazione del cambio di stato.
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_bug_reports.py`
### Denaro degli utenti
- **[AUD-16]** `deposit_credited` — un nuovo UTXO confermato sull'indirizzo di
un utente è stato accreditato, dopo la corroborazione degli altri server
(vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). Una riga
per UTXO. Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì,
`round_id` no. Stessa transazione dell'inserimento in `utxo_events` e del
ricalcolo del saldo. Attenzione: non riguarda solo i depositi dall'esterno.
Anche **le vincite** (l'uscita del pagamento verso il vincitore) e **i resti**
di scommesse e prelievi arrivano all'indirizzo dell'utente e vengono
accreditati per la stessa strada, quindi producono anche loro un
`deposit_credited`.
*Codice:* `app/deposits/service.py:credit_confirmed_utxos` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-17]** `utxo_spent_externally` — un UTXO che il database considerava
non speso non è più tra quelli non spesi, secondo il server attivo e la
maggioranza degli altri: è stato speso fuori dalla piattaforma (per esempio
con la chiave esportata). Una riga per UTXO. Payload
`{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no. Stessa
transazione della marcatura (`spent_txid = "external-spend"`) e del ricalcolo
del saldo.
*Codice:* `app/deposits/service.py:mark_utxos_spent_externally` ·
*Test:* `tests/unit/test_deposits.py`
- **[AUD-18]** `utxo_external_spend_reinstated` — un UTXO segnato come speso
all'esterno è ricomparso tra i non spesi e la marcatura è stata annullata.
Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no.
Stessa transazione dell'annullamento e del ricalcolo del saldo.
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
*Test:* `tests/unit/test_deposits.py`
- **[AUD-19]** `bet_placed` — la rete ha accettato la transazione di una
scommessa. Payload `{"txid", "amount_sats"}`: `txid` è quello del primo
invio (un successivo RBF non aggiorna la riga), `amount_sats` è l'importo
che arriva al montepremi, cioè la puntata **al netto** della commissione di
rete. `user_id` sì, `round_id` sì. Stessa transazione che porta il
partecipante a `broadcast` e la transazione pendente a `pending`.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[AUD-20]** `bet_broadcast_failed` — la rete ha rifiutato la transazione di
una scommessa; gli UTXO sono stati liberati e il partecipante rimosso.
Payload `{"txid", "reason"}`, con `reason` il testo dell'errore troncato a
200 caratteri. `user_id` sì, `round_id` **no** (a differenza di
`bet_placed`). Stessa transazione del ripristino.
*Codice:* `app/bets/service.py:_release_failed_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un
prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`:
`amount_sent_sats` è quanto arriva davvero all'indirizzo esterno (al netto
della commissione e dell'eventuale limite di polvere, vedi
[scommesse-e-prelievi.md](scommesse-e-prelievi.md)). L'id del prelievo non è
nel payload. `user_id` sì, `round_id` no. Stessa transazione che porta il
prelievo a `broadcast`.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-22]** `withdrawal_broadcast_failed` — la rete ha rifiutato la
transazione di un prelievo; gli UTXO sono stati liberati e il prelievo
segnato `failed`. Payload `{"txid", "reason"}` (`reason` troncato a 200
caratteri). `user_id` sì, `round_id` no. Stessa transazione del ripristino.
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* nessun test verifica la riga di audit.
### Round
- **[AUD-23]** `round_closed` — un round è stato chiuso **senza partecipanti
confermati**. Payload `{"participants": 0}`. `user_id` no, `round_id` sì.
Stessa transazione del passaggio a `closed`. È l'unico caso in cui la
chiusura di un round viene registrata: un round chiuso dopo la conferma del
pagamento non produce alcuna riga (vedi [AUD-32]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-24]** `winner_drawn` — il vincitore è stato estratto. Payload
`{"winner_address", "pool_amount_sats", "block_height", "block_hash",
"participants"}`: indirizzo del vincitore, montepremi totale (somma delle
puntate nette), altezza e hash del blocco usato come seme, numero di
partecipanti. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
che salva i dati dell'estrazione sul round e lo porta a `paying_out`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-25]** `draw_baseline_tip_unknown` — al momento della chiusura
l'altezza della catena non era nota, quindi il primo blocco ricevuto è stato
preso come base e l'estrazione aspetta il blocco successivo. Payload
`{"baseline_height"}`. `user_id` no, `round_id` sì. Transazione propria
([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza
un'altezza nota), vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-26]** `draw_header_corroboration_failed` — il blocco candidato per
l'estrazione non è stato confermato dalla maggioranza degli altri server
Electrum (o nessuno ha risposto); l'estrazione aspetta un blocco ulteriore.
Una riga per ogni blocco candidato rifiutato. Payload
`{"height", "reported_hash"}`. `user_id` no, `round_id` sì. Transazione
propria ([AUD-03]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-27]** `draw_stalled` — l'estrazione aspetta un nuovo blocco da più di
360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`), contati da
`drawing_started_at`; la riga si ripete ogni 360 secondi finché l'attesa
continua. Payload `{"tip_at_close", "current_tip_height",
"elapsed_seconds"}`. Nota: `tip_at_close` è la base *attuale* dell'attesa, che
dopo un `draw_header_corroboration_failed` diventa l'altezza del candidato
rifiutato, non più quella della chiusura. `user_id` no, `round_id` sì.
Transazione propria ([AUD-03]). Non viene scritta mentre è in corso una
corroborazione, solo mentre si aspetta un blocco.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito.
Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne
quando il motivo è l'assenza di connessione (vuoto); `round_id` sì.
Transazione propria e *best effort* ([AUD-03]); è l'orologio del nuovo
tentativo ([AUD-09]). Valori possibili di `reason`:
| `reason` | Causa |
|---|---|
| `electrum client not connected` | Nessuna connessione Electrum al momento del tentativo |
| `no fee_address configured` | `fee_address` vuoto nella configurazione |
| `winner user not found` | L'utente vincitore non esiste più nel database |
| `insufficient pool UTXOs` | Gli UTXO confermati e non riservati dell'indirizzo del montepremi non coprono l'importo |
| `too_many_inputs` | Servirebbero più di `MAX_PAYOUT_TX_INPUTS` (500) input |
| `winner_share_below_network_fee` | La quota del vincitore non copre la commissione di rete |
| `commission_below_dust_limit` | La quota della piattaforma è sotto il limite di polvere |
| `payout build failed` | Qualunque altro errore nella costruzione (per esempio un `fee_address` malformato) |
| `broadcast rejected` | La rete ha rifiutato la transazione; la riga `building` resta al riconciliatore |
Un tentativo saltato perché esiste già un pagamento `building` o `pending`
per il round **non** scrive `payout_failed`. Cosa fare per ogni motivo è
descritto in [operativita.md](operativita.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
*Test:* `tests/unit/test_scheduler.py`
- **[AUD-29]** `payout_sent` — la rete ha accettato la transazione di
pagamento. Payload `{"txid", "winner_sats", "commission_sats"}`: quanto
riceve il vincitore (già al netto della commissione di rete) e quanto va a
`fee_address`. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
che porta la transazione pendente da `building` a `pending`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_scheduler.py`
### Riconciliazione
- **[AUD-30]** `pending_tx_recovered` — una transazione rimasta `building`
(tipicamente per un riavvio a metà invio) risulta invece presente in catena
ed è stata promossa a `pending`. Payload
`{"pending_transaction_id", "kind", "txid"}`. `user_id` e `round_id`
dipendono dal tipo: sono quelli della riga `pending_transactions` (per un
pagamento `user_id` è vuoto, per un prelievo `round_id` è vuoto). Stessa
transazione della promozione. Vedi
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* nessun test verifica la riga di audit.
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
non compare nella cronologia del proprio indirizzo ed è stata dichiarata
persa: stato `failed`, UTXO liberati, partecipante rimosso o prelievo
`failed`, `payout_txid` del round azzerato. Payload
`{"pending_transaction_id", "kind", "txid", "reason", "utxos_released"}`, con
`reason` oggi sempre `"not found on chain"` e `utxos_released` il numero di
UTXO liberati (sempre 0 per un pagamento, perché l'indirizzo del montepremi
non è tracciato in `utxo_events`). `user_id` e `round_id` come in [AUD-30].
Stessa transazione dell'abbandono.
*Codice:* `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_reconcile.py`
## Cosa non viene registrato
- **[AUD-32]** Questi fatti **non** producono una riga di audit; restano
ricostruibili solo dallo stato delle tabelle o dal log applicativo:
- apertura di un round;
- passaggi di stato del round a `closing` e `drawing`, e la chiusura dopo la
conferma del pagamento (`_on_payout_confirmed`);
- conferma di una scommessa, di un prelievo o di un pagamento
(`ConfirmationPoller`);
- ogni aumento di commissione RBF e ogni RBF impossibile (solo log);
- registrazione, login, login falliti e blocchi per troppi tentativi;
- cambio password fatto dall'utente (`POST /users/me/change-password`);
- invio di una segnalazione di bug;
- accesso in lettura al pannello admin (elenchi utenti, round, audit).
*Codice:* `app/rounds/service.py:open_new_round_if_needed`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
## Log applicativo
Il registro di audit è nel database e si vede da `/admin`; il log applicativo
è un file di testo e contiene molto di più (errori, riconnessioni, attese), ma
va letto sul server.
- **[AUD-33]** All'importazione di `app.main`, `setup_logging` aggiunge al
logger radice (livello `INFO`) un `RotatingFileHandler` su `logs/app.log`,
relativo alla radice del progetto: 10 MB per file, 5 file di rotazione
(`app.log.1` … `app.log.5`), formato
`data ora LIVELLO [logger] messaggio`. Lo stesso handler è aggiunto a
`uvicorn.error` e `uvicorn.access`, che non propagano al radice. Nel
container la radice del progetto è `/app`, e `/app/logs` è montato su
`./data/logs` dell'host.
*Codice:* `app/logging_config.py:setup_logging`, `docker-compose.yml`
- **[AUD-34]** Il logger radice ha **solo** l'handler su file: i messaggi dei
moduli dell'applicazione (scheduler, listener, riconciliatori) finiscono in
`data/logs/app.log` e non sull'output del container. `docker compose logs app`
mostra solo ciò che uvicorn scrive con la propria configurazione (avvio,
richieste) e l'output di avvio del container (controllo della chiave,
migrazioni Alembic).
*Codice:* `app/logging_config.py:setup_logging`
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
arriva solo un errore generico `internal_error` con stato 500, senza il testo
dell'eccezione.
*Codice:* `app/main.py:log_unhandled_exception`
- **[AUD-36]** Le richieste di polling delle due interfacce (per esempio
`/rounds/current` ogni 15 secondi per ogni pagina aperta, più un aggiornamento
a ogni evento SSE) vengono tutte registrate da `uvicorn.access`. Con 60 MB di spazio complessivo, la rotazione può cancellare
in poche ore o giorni i messaggi più vecchi: il log non è un archivio, il
registro di audit sì.
*Codice:* `app/logging_config.py:setup_logging`
### Messaggi di log che contano
Messaggi non accompagnati da una riga di audit ma utili in esercizio. Il
nome tra parentesi quadre nel log è il modulo (`app.rounds.scheduler`, ecc.).
| Livello | Modulo | Messaggio (inizio) | Significato |
|---|---|---|---|
| WARNING | `app.electrum.listener` | `all N Electrum server(s) unreachable, retrying in Ns` | Tutti i server provati hanno fallito; attesa da 1 a 30s prima del giro successivo |
| ERROR (con traccia) | `app.electrum.listener` | `Electrum session on … failed` | Errore nell'aprire la sessione: connessione, primo header, primo header rifiutato |
| INFO | `app.electrum.listener` | `Electrum connected to …` / `Electrum connection to … ended, reconnecting` | Connessione aperta / chiusa normalmente |
| WARNING | `app.electrum.listener` | `Electrum session on … ending: …` | Motivo della fine di una sessione già aperta (ping scaduto, socket chiuso, header successivo rifiutato con `HeaderValidationError`) |
| WARNING | `app.electrum.listener` | `ignoring Electrum header at height …` / `ignoring a competing header …` | Header scartato senza chiudere la sessione (senza hex, più basso, concorrente) |
| WARNING | `app.electrum.listener` | `could not corroborate … with any of N other configured server(s)` | Nessun altro server ha risposto: la corroborazione fallisce |
| WARNING | `app.electrum.listener` | `N new UTXO(s) for user_id=… not corroborated by the other servers` | Deposito non ancora accreditato; si riprova al prossimo aggiornamento |
| WARNING | `app.electrum.listener` | `N UTXO(s) spent outside the platform for user_id=…` | Accompagna `utxo_spent_externally` |
| WARNING | `app.electrum.listener` | `could not subscribe a newly-registered user's address` / `resubscribing all users failed partway through` | Sottoscrizione persa; la recupera `DepositReconciler` entro 300s |
| ERROR (con traccia) | `app.electrum.listener` | `failed to resubscribe user_id=…` | Un singolo utente non risottoscritto alla riconnessione |
| WARNING | `app.deposits.service` | `listunspent for user_id=… returned no entries at all …` | Risposta vuota per un indirizzo con fondi: ignorata come incompleta |
| ERROR (con traccia) | `app.deposits.reconcile` | `deposit reconciliation failed for user_id=…` | Il controllo periodico di un utente è fallito |
| ERROR (con traccia) | `app.rounds.scheduler` | `round scheduler tick failed` | Eccezione in un ciclo dello scheduler. Se avviene durante `drawing`, il round resta bloccato (vedi [operativita.md](operativita.md)) |
| INFO | `app.rounds.scheduler` | `round N: N bet(s) still in flight at close time, waiting` | Chiusura rinviata per scommesse ancora in volo |
| INFO | `app.rounds.scheduler` | `round N: still waiting for a block past height …` | Estrazione in attesa, ogni 60s |
| ERROR | `app.rounds.scheduler` | `round N: block … header … could not be corroborated …` | Accompagna `draw_header_corroboration_failed` |
| WARNING | `app.rounds.scheduler` | `round N: chain tip was unknown at closing time …` | Accompagna `draw_baseline_tip_unknown` |
| ERROR | `app.rounds.scheduler` | `round N payout blocked: …` / `payout deferred: not connected` / `payout failed: …` / `payout build failed` / `payout broadcast failed` | Accompagnano i vari `payout_failed` |
| INFO | `app.rounds.scheduler` | `round N payout already in flight (pending_transaction N), skipping` | Pagamento già in volo. Mentre aspetta la conferma, ricompare a ogni ciclo (ogni 5s) |
| ERROR (con traccia) | `app.rounds.scheduler` | `could not record the payout failure of round N` | Il `payout_failed` stesso non è stato salvato |
| ERROR | `app.rounds.service` | `invariant violated: N rounds are active at once` | Più di un round attivo: non dovrebbe mai accadere |
| ERROR | `app.rounds.service` | `could not open a round after 3 attempts` | Conflitti ripetuti all'apertura di un round |
| ERROR (con traccia) | `app.tx.broadcast` | `could not bump pending_transaction N` | RBF impossibile (resto insufficiente o commissione già al massimo). Si ripete ogni 30s finché la transazione conferma o viene abbandonata |
| INFO | `app.tx.broadcast` | `bumped <tipo> pending_transaction N: <vecchio> -> <nuovo>` | RBF riuscito: l'unica traccia di un aumento di commissione |
| WARNING | `app.tx.confirmation` | `could not fetch history for pending_transaction …` | Controllo di conferma non riuscito, si riprova tra 10s |
| WARNING | `app.tx.reconcile` | `could not check pending_transaction … against the chain` | Il riconciliatore non ha potuto decidere; non abbandona nulla |
| WARNING | `app.tx.reconcile` | `abandoned <tipo> pending_transaction …` | Accompagna `pending_tx_abandoned` |
| ERROR | `app.tx.reconcile` | `round N payout tx … vanished — round needs operator attention` | Pagamento abbandonato; il nuovo tentativo automatico parte da solo |
| ERROR | `app.auth.security` | `stored password hash is unparseable …` | Hash di password corrotto nel database: quell'utente non può accedere |
| ERROR (con traccia) | `app.main` | `Unhandled error on …` | Errore 500 su una richiesta ([AUD-35]) |
+344
View File
@@ -0,0 +1,344 @@
# Ciclo di vita del round
Fase 4 del [funzionamento generale](come-funziona.md): come un round si apre,
si chiude, estrae un vincitore e lo paga. Tutto è guidato da `RoundScheduler`,
che si risveglia ogni 5 secondi.
I requisiti usano il prefisso `RND`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
`rounds` sono descritte in [modello-dati.md](modello-dati.md), gli eventi di
audit in [audit-log.md](audit-log.md), le attività in background in
[architettura.md](architettura.md).
- **[RND-01]** Lo scheduler esegue un passo ogni 5 secondi
(`_TICK_INTERVAL_SECONDS`). Un passo non fa nulla se non c'è connessione a
Electrum: senza rete nessun round si apre, si chiude o si paga. Un errore in
un passo viene registrato nei log e non ferma i passi successivi.
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
## Gli stati
```mermaid
stateDiagram-v2
[*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in manutenzione,<br/>indirizzo commissioni impostato
open --> closing: scade il tempo<br/>(al primo passo dopo la scadenza)
closing --> closing: scommesse ancora in volo
closing --> closed: nessun partecipante confermato
closing --> drawing: tutte le scommesse confermate
drawing --> drawing: blocco non corroborato,<br/>si attende il successivo
drawing --> paying_out: nuovo blocco corroborato,<br/>vincitore scelto
paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s
paying_out --> closed: pagamento confermato
closed --> [*]
```
| Stato | Significato |
|---|---|
| `open` | Accetta scommesse fino alla scadenza |
| `closing` | Scadenza passata: nessuna nuova scommessa, si aspettano quelle già inviate |
| `drawing` | Partecipanti fissati, si attende il blocco che sceglie il vincitore |
| `paying_out` | Vincitore noto, pagamento in costruzione, inviato o in attesa di conferma |
| `closed` | Concluso (con o senza vincitore) |
- **[RND-02]** Gli stati sono esattamente quelli della tabella e le sole
transizioni sono quelle del diagramma; `closed` è definitivo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_confirmation.py`
- **[RND-03]** **Al massimo un round attivo alla volta** (cioè in uno stato
diverso da `closed`). Non è una convenzione ma un vincolo del database: un
indice univoco (`ix_rounds_single_active`) fa fallire un secondo inserimento
concorrente, e il codice di apertura in quel caso adotta il round già creato
(fino a 3 tentativi, `_OPEN_ROUND_ATTEMPTS`). Se mai risultassero due round
attivi, si usa il più recente e lo si segnala nei log come violazione.
*Codice:* `app/db/models.py:Round`, `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:get_active_round` ·
*Test:* `tests/unit/test_rounds_service.py`
## 0 · Apertura
L'apertura viene tentata a ogni passo dello scheduler e anche da ogni
richiesta di scommessa (vedi
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata)).
- **[RND-04]** Un nuovo round si apre solo quando **tutte** queste condizioni
sono vere:
- nessun round è attivo (il precedente è `closed`);
- è trascorsa la **pausa tra i round** del round chiuso più recente, contata
dal suo `closed_at`: per un round pagato è il momento della conferma del
pagamento, per un round chiuso senza partecipanti è il momento della
chiusura. Serve a lasciare ai giocatori il tempo di vedere il risultato;
- la lotteria **non è in manutenzione** (`POST /admin/pause`);
- l'**indirizzo delle commissioni** (`fee_address`) è impostato (un valore
di soli spazi conta come vuoto). Senza, il pagamento non si potrebbe
costruire, quindi il round non viene nemmeno aperto invece di bloccarsi
più avanti con i soldi già nel montepremi.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_service.py`
- **[RND-05]** All'apertura il round **fotografa** la durata
(`round_duration_seconds`, 600s predefiniti) e la pausa
(`round_cooldown_seconds`, 30s predefiniti) correnti. Scadenza del round e
pausa successiva usano sempre i valori fotografati: una modifica da
`/admin` vale dal round successivo, mai per quello in corso.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py`
- **[RND-06]** La pausa di manutenzione e l'indirizzo mancante bloccano solo
l'apertura del *prossimo* round: un round già in corso prosegue fino al
pagamento, anche se l'indirizzo viene cancellato nel frattempo.
`GET /rounds/current` espone i due casi separatamente (`lottery_paused`,
`lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in
[guida-admin.md](guida-admin.md).
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`
- **[RND-07]** L'apertura di un round invia una notifica SSE.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`
## 1 · Round aperto
Il round accetta scommesse fino a `apertura + durata`. Le regole di
accettazione sono in
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata).
- **[RND-08]** La scadenza (`closes_at`) è `opened_at` più la durata
fotografata all'apertura; è la stessa usata per rifiutare le scommesse e per
chiudere il round.
*Codice:* `app/rounds/service.py:round_deadline`, `app/rounds/service.py:round_accepts_bets` ·
*Test:* `tests/unit/test_rounds_service.py`
- **[RND-09]** `GET /rounds/current` pubblica il montepremi in due forme:
- `jackpot_sats` e `participant_count`: calcolati sulle sole scommesse
**confermate** — esattamente ciò che l'estrazione considererà e che il
pagamento potrà spendere. `jackpot_sats` è già il 70% del vincitore
(arrotondato per difetto) della somma degli importi netti, non l'intero
montepremi; resta un limite superiore, perché la commissione del pagamento
sarà dedotta da lì;
- `pending_jackpot_sats` e `pending_participant_count`: gli stessi valori
includendo le scommesse ancora in volo (`building`, `broadcast`), come
totali e non come differenze; `has_pending_bets` dice se differiscono.
*Codice:* `app/api/routes/rounds.py:current_round`, `app/rounds/service.py:winner_share` ·
*Test:* `tests/unit/test_rounds_route.py`
- **[RND-10]** `user_played` è vero solo se chi chiama è autenticato e ha una
scommessa nel round attivo; senza login la risposta non è personalizzata.
*Codice:* `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_rounds_route.py`
L'interfaccia mostra in grande i valori confermati e la differenza come
suffisso ambra "+N in attesa": chi ha appena scommesso vede subito la propria
giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato.
## 2 · Chiusura
- **[RND-11]** Al primo passo dello scheduler dopo la scadenza (quindi entro
circa 5 secondi) il round passa a `closing`, qualunque cosa stia accadendo:
si annota `closed_at` e si invia una notifica SSE. Le scommesse sono però
rifiutate già dall'istante della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-12]** Poi il round **attende che ogni scommessa già inviata sia
confermata** (il "semaforo giallo"): finché esiste un partecipante in stato
`building` o `broadcast` il round resta `closing` e il controllo si ripete
al passo successivo. Il controllo viene ripetuto anche nella stessa sessione
del database in cui si fissa l'elenco dei partecipanti, così una scommessa
salvata tra i due controlli non può restare esclusa. Una scommessa partita
un attimo prima della scadenza resta quindi valida e non va persa. Ciò che
impedisce all'attesa di durare per sempre è il riconciliatore, che prima o
poi abbandona una scommessa che non conferma mai
([TX-17](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-13]** Quando non resta nulla in volo e **nessun partecipante è
confermato**, il round passa a `closed` senza vincitore, si scrive
`round_closed` (con `participants: 0`) e si invia una notifica SSE.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-14]** Con **almeno un partecipante confermato**, si fissa l'elenco dei
partecipanti confermati, ordinato per momento di invio della scommessa, si
somma il montepremi dai loro importi netti, e il round passa a `drawing`
annotando `drawing_started_at`; si invia una notifica SSE. L'altezza della
catena nota in quell'istante diventa il riferimento dell'estrazione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
## 3 · Estrazione
L'estrazione usa come fonte di casualità l'**hash di un blocco confermato
dopo la chiusura**, un valore che nessuno conosceva mentre le scommesse erano
aperte.
```mermaid
flowchart TB
A["Altezza nota alla chiusura<br/>(se sconosciuta: la prima che si<br/>viene a sapere, draw_baseline_tip_unknown)"] --> B{"La punta della catena<br/>è più alta?<br/>(controllo ogni 5s)"}
B -- "No" --> B
B -- "Sì" --> C{"La maggioranza degli<br/>altri server conferma<br/>l'hash a quell'altezza?"}
C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed),<br/>quell'altezza diventa il nuovo riferimento"] --> B
C -- "Sì" --> E["seme = hash come numero intero<br/>indice = seme mod numero partecipanti"]
E --> F["Vincitore = partecipante all'indice"]
```
- **[RND-15]** L'estrazione attende che la punta della catena superi
l'altezza di riferimento, controllando ogni 5 secondi; il blocco candidato è
la punta nota in quel momento (di norma il primo blocco dopo la chiusura).
Per tutta l'attesa lo scheduler resta dentro l'estrazione e non esegue
altri passi.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-16]** **Blocco corroborato**: l'header ricevuto dal server Electrum
attivo è l'unica fonte di casualità, quindi un server malevolo che lo
falsificasse sceglierebbe il vincitore. Per questo, prima di usarlo, lo si fa
confermare dagli altri server
([ELC-15](connessione-electrum.md#5-il-quorum)). Se la conferma manca, si
scrive `draw_header_corroboration_failed` (altezza e hash riportato), il
blocco non viene mai usato e si attende un blocco ulteriore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-17]** **Algoritmo**: seme = hash del blocco letto come numero
esadecimale, indice = seme modulo numero dei partecipanti, vincitore = il
partecipante in quella posizione dell'elenco ordinato per momento di invio.
**Probabilità uguale per tutti**: ogni partecipante ha una e una sola
posizione nell'elenco, indipendentemente dall'importo.
*Codice:* `app/rounds/draw.py:draw_winner`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_draw.py`
- **[RND-18]** **Verificabile**: il round salva altezza e hash del blocco
(`draw_block_height`, `draw_block_hash`), il seme (`seed_int`), il
vincitore e il montepremi (`pool_amount_sats`), passa a `paying_out`,
scrive `winner_drawn` (indirizzo del vincitore, montepremi, altezza, hash,
numero di partecipanti) e invia una notifica SSE. Chiunque, con l'hash del
blocco e l'elenco dei partecipanti, può rifare il calcolo. Il primo
tentativo di pagamento parte subito dopo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-19]** **Altezza iniziale sconosciuta**: se alla chiusura l'altezza
della catena non era nota (valore 0), non si parte da zero (qualunque blocco
già esistente sarebbe "successivo", e quindi prevedibile): si attende il
primo header, se ne adotta l'altezza come riferimento, si scrive
`draw_baseline_tip_unknown` e si aspetta un blocco strettamente successivo.
In condizioni normali non accade, perché la connessione è resa disponibile
solo con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-20]** **Attesa lunga ma visibile**: l'attesa non ha un limite,
perché dipende davvero dall'arrivo di un blocco. Ogni 60 secondi se ne
registra l'avanzamento nei log; dopo 6 minuti (`_DRAW_STALL_THRESHOLD_SECONDS`
= 360s) viene scritto un evento `draw_stalled`, ripetuto ogni 6 minuti
finché l'attesa continua. Durante `drawing`, `GET /rounds/current` espone
`draw_waiting_since` (l'orario di inizio dell'attesa); negli altri stati il
campo è assente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_route.py`
L'algoritmo è volutamente semplice ed è pensato per essere **sostituito**
(vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
## 4 · Pagamento
Con il vincitore noto il round è in `paying_out` e si costruisce il
pagamento, firmato con la chiave del montepremi.
- **[RND-21]** **Ripartizione**: il vincitore riceve il 70% del montepremi
arrotondato per difetto, l'indirizzo delle commissioni il resto (30% più
l'eventuale resto dell'arrotondamento). La ripartizione è fissata nel
codice, non modificabile da `/admin`.
*Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_payout_builder.py`
- **[RND-22]** Il pagamento spende le monete **confermate** dell'indirizzo del
montepremi, lette dal server Electrum attivo ed escluse quelle già
impegnate da un altro pagamento non risolto ([RND-26]), scelte dalla più
grande fino a coprire il montepremi e al massimo 500
(`MAX_PAYOUT_TX_INPUTS`). Non abbastanza monete dà il motivo
`insufficient pool UTXOs`, troppe `too_many_inputs`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore, una
verso l'indirizzo delle commissioni e, se supera 294 sat, una di resto verso
il montepremi (un resto inferiore va alla commissione di rete). La
**commissione di rete** (stimata su tre uscite, a `fee_rate_sat_vb`) è
pagata dalla quota del vincitore, così il 30% resta intatto. Se la quota del
vincitore meno la commissione, o la quota delle commissioni, scende sotto
294 sat, il pagamento non si costruisce (`winner_share_below_network_fee`,
`commission_below_dust_limit`).
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py`
- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round
registra importo del vincitore (`winner_amount_sats`), commissioni
(`fee_amount_sats`) e txid (`payout_txid`), e si salva una transazione in
corso di tipo `payout` in stato `building`. Se l'invio riesce, la
transazione passa a `pending` e si scrive `payout_sent`. Se fallisce, la
riga resta `building` e si scrive `payout_failed`: sarà il riconciliatore a
constatare che la rete non la conosce, abbandonarla e liberare il round per
un nuovo tentativo ([TX-18](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-25]** Ogni tentativo che non arriva all'invio riuscito scrive un
evento `payout_failed` con il motivo: `electrum client not connected`,
`no fee_address configured`, `winner user not found`,
`insufficient pool UTXOs`, il codice dell'errore di costruzione
(`too_many_inputs`, `winner_share_below_network_fee`,
`commission_below_dust_limit`), `payout build failed` per ogni altro errore
di costruzione, `broadcast rejected`. A ogni passo lo scheduler riesamina un
round in `paying_out` e riprova **al massimo una volta ogni 60 secondi**
(`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo `payout_failed` di
quel round; senza alcun `payout_failed` (ad esempio dopo un riavvio) riprova
subito.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-26]** Due protezioni impediscono di pagare due volte: non si
costruisce un nuovo pagamento se per il round ne esiste già uno `building` o
`pending` (il tentativo termina senza fare nulla), e le monete del
montepremi impegnate da un qualunque pagamento non risolto, anche di un
round precedente, sono escluse dalla selezione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-27]** Se il pagamento non conferma in tempo si applica l'RBF, pagato
dal resto del montepremi ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
*Codice:* `app/tx/broadcast.py:bump_fee`
- **[RND-28]** Quando il pagamento è confermato il round passa a `closed` e
`closed_at` viene riscritto con l'orario della conferma: da lì parte la
pausa prima del round successivo. Il vincitore si vede accreditare la
vincita come un normale deposito
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
## Tempi reali
Tra lo scadere del timer e l'arrivo della vincita ci sono **tre conferme
distinte**, ognuna in un blocco diverso:
1. l'ultima scommessa in volo (può essere già avvenuta prima della scadenza);
2. il blocco dell'estrazione, necessariamente successivo al punto 1;
3. il pagamento, costruito solo dopo il punto 2.
Con blocchi da 120 secondi sono circa **2–4 minuti** nel caso migliore e
**4–6** nel peggiore, indipendentemente da `draw_animation_seconds`.
## Cosa vede il giocatore
Due livelli indipendenti:
- **[RND-29]** A **tutti** i visitatori, per l'intera durata di `closing`,
`drawing` e `paying_out`, un riquadro con la fase in corso (ad esempio
"Pagamento al vincitore in corso…").
*Codice:* `app/static/app.js:refreshRound`
- **[RND-30]** **In più**, solo a chi ha giocato quel round
(`user_played`), il riquadro personale "Hai vinto!" / "Non hai vinto". Il
risultato compare quando il vincitore è noto (quindi da `paying_out`), non
prima di `draw_animation_seconds` (20s predefiniti) dall'orario di chiusura
del server `closes_at` — così ricaricare la pagina non fa ripartire il conto
alla rovescia — e, per il vincitore, solo quando è noto l'importo vinto.
*Codice:* `app/static/app.js:refreshRound`
- **[RND-31]** Una volta rivelato, il risultato resta salvato nel browser
(`localStorage.plm_persisted_result`) anche dopo che il round è `closed`
(quando `GET /rounds/current` non lo restituisce più), fino all'apertura di
un round nuovo. Per un dispositivo che si è perso del tutto la finestra,
`GET /users/me/last-round-result` restituisce dal database l'ultimo round
`closed` a cui l'utente ha partecipato, se ha vinto e l'importo vinto.
*Codice:* `app/static/app.js:checkLastRoundResult`, `app/api/routes/users.py:last_round_result`
## Limite noto
- **[RND-32]** Se il server si riavvia mentre un round è in `drawing`,
l'attesa del blocco **non riprende da sola**: lo scheduler riprende i round
in `closing` e `paying_out`, ma su un round in `drawing` non fa nulla. È
l'ultimo prerequisito mancante per un funzionamento completamente non
presidiato (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`
+151
View File
@@ -0,0 +1,151 @@
# Come funziona PLM Lottery
Panoramica del funzionamento della piattaforma. È il punto di partenza: ogni
sezione rimanda al documento che la approfondisce.
| Documento | Cosa spiega |
|---|---|
| [registrazione-e-depositi.md](registrazione-e-depositi.md) | Come nasce un account, come arrivano i fondi, come si calcola il saldo mostrato |
| [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Come si piazza una scommessa e come si ritirano i fondi |
| [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | Apertura, chiusura, estrazione del vincitore, pagamento |
| [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | Come ogni movimento di denaro viene registrato, inviato, accelerato (RBF) e riconciliato con la catena |
| [connessione-electrum.md](connessione-electrum.md) | Come il server parla con la rete PLM e perché non si fida di un solo server |
| [architettura.md](architettura.md) | Struttura del codice, processi in background, configurazione, frontend, traduzioni, test |
| [modello-dati.md](modello-dati.md) | Tabelle del database, campi, stati, vincoli, migrazioni |
| [api.md](api.md) | Tutti gli endpoint HTTP, formato delle risposte, catalogo dei codici di errore |
| [sicurezza.md](sicurezza.md) | Modello di fiducia, chiavi e segreti, autenticazione, limiti sui tentativi, header HTTP |
| [audit-log.md](audit-log.md) | Catalogo degli eventi del registro di audit |
| [operativita.md](operativita.md) | Backup, riavvii, procedure per gli incidenti |
| [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto con le loro motivazioni, limiti noti accettati |
| [storico-bug.md](storico-bug.md) | Tutti i bug B-nn trovati e corretti, con commit e test |
Questi documenti sono la **specifica** del software: ogni regola ha un
identificativo stabile (ad esempio `RND-11`) con il riferimento al codice che
la implementa. Come si usano per modificare il software è spiegato in
[processo-di-modifica.md](processo-di-modifica.md).
Per installazione e avvio vedi [setup.md](setup.md) e
[running-the-server.md](running-the-server.md); per l'uso delle interfacce
[guida-utente.md](guida-utente.md) e [guida-admin.md](guida-admin.md).
## In una frase
Gli utenti depositano PLM su un indirizzo personale custodito dal server,
comprano un biglietto a costo fisso per il round in corso, e allo scadere del
round l'hash del primo blocco successivo sceglie un vincitore, che riceve il
70% del montepremi; il 30% va alla piattaforma.
## Scelte di fondo
- **Custodiale.** Le chiavi sono generate e tenute dal server (una master xprv
cifrata a riposo). L'utente non controlla nulla finché non preleva.
- **Un indirizzo per utente, per sempre.** Lo stesso indirizzo serve per i
depositi, riceve le vincite e riceve il resto di ogni scommessa e prelievo.
- **Nessun nodo completo.** Tutto l'accesso alla rete passa dal protocollo
Electrum (vedi [connessione-electrum.md](connessione-electrum.md)).
- **1 conferma** per ogni tipo di transazione: depositi, scommesse, pagamenti,
prelievi.
- **Parametri operativi nel database**, modificabili a caldo da `/admin`
(costo della scommessa, durata del round, commissioni…). Nel file `.env`
restano solo segreti e collegamenti infrastrutturali.
## Le cinque fasi
```mermaid
flowchart LR
subgraph REG["1 · Registrazione"]
direction TB
A1["Username + password"] --> A2["Il server deriva un indirizzo<br/>dedicato e permanente"]
end
subgraph DEP["2 · Deposito"]
direction TB
B1["L'utente invia PLM<br/>al proprio indirizzo"] --> B2{"1 conferma e<br/>corroborato dagli<br/>altri server?"}
B2 -- "No" --> B1b["Si riprova al prossimo<br/>aggiornamento"] --> B2
B2 -- "Sì" --> B3["Saldo accreditato"]
end
subgraph PLAY["3 · Scommessa"]
direction TB
C1{"Saldo sufficiente,<br/>nessuna scommessa attiva,<br/>round aperto e non pieno?"}
C1 -- "Sì" --> C2["Transazione: indirizzo utente →<br/>montepremi, resto all'utente"]
C2 --> C3{"Confermata?"}
C3 -- "No, troppo tempo" --> C4["Commissione aumentata<br/>(RBF) e reinvio"] --> C3
C3 -- "Sì" --> C5["Partecipante del round"]
end
subgraph DRAW["4 · Round ed estrazione"]
direction TB
D1["Scadenza del round"] --> D2["Si attendono le scommesse<br/>già in volo"]
D2 --> D3["Il primo blocco successivo<br/>sceglie il vincitore"]
D3 --> D4["70% al vincitore,<br/>30% alla piattaforma"]
end
subgraph WITHDRAW["5 · Prelievo"]
direction TB
E1["Indirizzo esterno + importo"] --> E2["Transazione: indirizzo utente →<br/>esterno, resto all'utente"]
E2 --> E3{"Confermata?"}
E3 -- "No, troppo tempo" --> E4["RBF e reinvio"] --> E3
E3 -- "Sì" --> E5["Saldo aggiornato"]
end
A2 --> B1
B3 --> C1
B3 --> E1
C5 --> D1
D4 -.->|"round successivo"| C1
```
1. **Registrazione** — [registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione)
2. **Deposito** — [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)
3. **Scommessa** — [scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa)
4. **Round ed estrazione** — [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)
5. **Prelievo** — [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo)
## Componenti
Un solo processo Python (FastAPI) dietro Caddy, con un database SQLite. Dentro
il processo girano, oltre all'API HTTP, sei attività in background avviate
all'avvio del server:
| Attività | Ogni | Cosa fa |
|---|---|---|
| `ElectrumListener` | connessione continua, ping ogni 60s | L'unica connessione alla rete: riceve i nuovi blocchi, osserva gli indirizzi di tutti gli utenti, accredita i depositi |
| `RoundScheduler` | 5s | Apre e chiude i round, esegue l'estrazione, avvia e riprova i pagamenti |
| `ConfirmationPoller` | 10s | Porta le transazioni da "in attesa" a "confermate" |
| `RbfBumper` | 30s | Aumenta la commissione di ciò che non conferma entro il tempo limite |
| `PendingTransactionReconciler` | all'avvio, poi 120s | Confronta con la catena ogni transazione rimasta a metà |
| `DepositReconciler` | 300s | Ricontrolla tutti gli indirizzi, nel caso una notifica sia andata persa |
La cadenza di queste attività è ciò che determina quanto in fretta il sistema
si ripara da solo dopo un problema.
Le due interfacce web sono pagine statiche servite dallo stesso server, senza
build né framework:
- **`/`** — l'interfaccia per i giocatori (registrazione, deposito, scommessa,
prelievo, profilo, stato del round), tradotta in 7 lingue.
- **`/admin`** — il pannello operatore, protetto da un token condiviso,
in italiano.
Entrambe interrogano periodicamente l'API e in più ascoltano un canale
**SSE** (`GET /rounds/stream`): un semplice segnale "è cambiato qualcosa,
ricarica" inviato a ogni evento rilevante (round aperto o cambiato di stato,
scommessa o prelievo inviato, transazione confermata, deposito accreditato,
nuovo blocco). Se il canale cade, le pagine continuano a funzionare con il
solo polling.
## Parametri principali
| Parametro | Valore predefinito | Modificabile |
|---|---|---|
| Costo della scommessa | 10 PLM | da `/admin` |
| Ripartizione del montepremi | 70% vincitore / 30% piattaforma | no, è nel codice |
| Durata del round | 600s | da `/admin`, vale dal round successivo |
| Pausa tra un round e l'altro | 30s | da `/admin`, vale dal round successivo |
| Commissione di rete | 1 sat/vB | da `/admin` |
| Tempo prima di un RBF | 900s | da `/admin` |
| Prelievo minimo | pari al costo della scommessa | segue il costo |
| Deposito minimo | nessuno | — |
| Partecipanti massimi per round | 400 | no, è nel codice |
| Conferme richieste | 1 | no, è nel codice |
+186
View File
@@ -0,0 +1,186 @@
# Connessione alla rete PLM (Electrum)
Il server non gestisce un nodo completo: parla con la rete PLM esclusivamente
tramite il **protocollo Electrum**, con **una sola connessione** alla volta.
Da quella connessione passa tutto — accredito dei depositi, invio delle
transazioni, conferme, altezza della catena su cui attende l'estrazione — per
cui è sia il principale punto di rottura sia, se il server dall'altra parte
fosse ostile, il principale rischio per l'integrità.
I requisiti usano il prefisso `ELC`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). La configurazione
(`.env`) è descritta in [setup.md](setup.md), i rischi accettati in
[sicurezza.md](sicurezza.md) e [decisioni-e-limiti.md](decisioni-e-limiti.md).
Le difese sono cinque.
## 1. Rotazione tra server
`ELECTRUM_HOST`/`ELECTRUM_PORT` è il server principale;
`ELECTRUM_FALLBACK_SERVERS` è un elenco di riserva nel formato
`host:porta[:notls]`, separato da virgole.
- **[ELC-01]** L'elenco dei server è il principale (con TLS secondo
`ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è
`host:porta` (TLS) oppure `host:porta:<modo>`, con `<modo>` tra `ssl`/`tls`
(TLS) e `notls`/`plain` (in chiaro); le voci vuote sono ignorate e i
duplicati eliminati mantenendo la prima posizione. Una voce malformata
blocca l'avvio subito, non durante il guasto in cui servirebbe.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il
server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s
raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una
volta di seguito. Una sessione che si era stabilita azzera l'attesa. Senza
alcun server configurato il listener si ferma con un errore nei log.
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ELC-03]** Con TLS la connessione è cifrata ma il certificato del server
**non viene verificato** (i server Electrum usano spesso certificati
autofirmati): la fiducia non viene dal TLS ma dal quorum
([§ 5](#5-il-quorum)). Ogni connessione si apre con la stretta di mano
`server.version`.
*Codice:* `app/electrum/client.py:ElectrumClient.connect`
## 2. Richieste con limite di tempo
- **[ELC-04]** Ogni richiesta ha un limite di 15 secondi
(`_REQUEST_TIMEOUT_SECONDS`); allo scadere la connessione viene chiusa, la
sessione termina e si passa al server successivo. Quando la connessione cade,
tutte le richieste in attesa falliscono subito invece di restare sospese.
Senza questo limite una risposta mai arrivata poteva bloccare una scommessa
(con il blocco per-utente acquisito) o fermare per sempre il controllo delle
conferme.
*Codice:* `app/electrum/client.py:ElectrumClient.request`, `app/electrum/client.py:ElectrumClient._read_loop` ·
*Test:* `tests/unit/test_electrum_client.py`
## 3. Caduta rilevata
- **[ELC-05]** La connessione viene pingata (`server.ping`) ogni 60 secondi
(`_PING_INTERVAL_SECONDS`); un ping fallito chiude la sessione.
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive`
- **[ELC-06]** La chiusura della connessione è un evento osservato: la
sessione termina non appena la connessione si chiude, il ping fallisce o
l'elaborazione degli header o delle notifiche degli indirizzi si interrompe
con un errore. Il listener non resta quindi in attesa di notifiche che non
arriveranno mai mentre sembra ancora connesso; a sessione finita il client
non è più disponibile.
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
**solo dopo** che il primo header è stato applicato: se il client c'è,
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
del contenuto binario) e non si conosce ancora alcuna altezza, la sessione
viene rifiutata e si passa al server successivo. Chi trova il client assente
(ad esempio durante una riconnessione) salta semplicemente quel giro, e
scommesse e prelievi rispondono `network_unavailable`.
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once` ·
*Test:* `tests/unit/test_electrum_listener.py`
## 4. Header validati, non creduti
Ogni nuovo header di blocco — quello ricevuto all'iscrizione e ogni successivo
annunciato dal server — viene controllato prima di essere accettato.
Tre casi vengono semplicemente **ignorati**, con un avviso nei log e senza
chiudere la sessione, perché non indicano un server ostile:
- **[ELC-08]** un header per un'altezza **inferiore** a quella nota: l'altezza
non torna mai indietro;
- **[ELC-09]** un header per l'altezza **già nota** (lo stesso blocco
riannunciato, una riorganizzazione in punta, o un server in disaccordo):
l'hash già adottato per quell'altezza non viene mai sostituito; se fosse
quello sbagliato, è la corroborazione dell'estrazione a scartarlo;
- **[ELC-10]** un header privo del contenuto binario: non c'è nulla da
validare né da usare per l'estrazione.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_electrum_listener.py`
Due controlli invece, se falliscono, **chiudono la sessione** e fanno passare
a un altro server: quell'header è l'unica fonte di casualità dell'estrazione,
e uno falsificato sceglierebbe il vincitore.
- **[ELC-11]** L'header deve essere lungo 80 byte e il suo hash deve
rispettare la difficoltà che l'header stesso dichiara (campo `bits`).
*Codice:* `app/rounds/draw.py:header_meets_its_own_target`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
- **[ELC-12]** Un avanzamento di **un solo** blocco deve agganciarsi all'hash
del blocco corrente. Per un salto di più blocchi questo controllo non è
possibile senza la catena intera, e vale solo quello di difficoltà.
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della
punta (mai uno senza l'altro). Ogni notifica di nuovi header invia una
notifica SSE, perché è proprio ciò che l'estrazione attende.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._consume_headers` ·
*Test:* `tests/unit/test_electrum_listener.py`
Il controllo di difficoltà respinge un header mai minato, ma non uno minato
davvero a una difficoltà dichiarata irrealisticamente bassa: per questo il
blocco dell'estrazione viene anche corroborato ([ELC-15]).
## 5. Il quorum
Ogni decisione che sposta denaro viene confermata dagli **altri** server
configurati, mai da quello attivo (che è proprio quello che un attaccante
controllerebbe).
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
configurati tranne quello attivo, ciascuno con una connessione nuova e un
limite di 10 secondi (`_CORROBORATION_TIMEOUT_SECONDS`) per collegarsi e 10
per rispondere. Passa se concorda la **maggioranza stretta dei server che
hanno risposto**; un server che non risponde o dà errore non conta.
- **Nessun server di riserva configurato** → la verifica passa. È il rischio
accettato di un `ELECTRUM_FALLBACK_SERVERS` vuoto: in produzione conviene
configurarne più d'uno (vedi [setup.md](setup.md)).
- **Nessuno risponde** → la verifica **fallisce**: una rete irraggiungibile
non prova nulla.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
| ID | Prima di… | Si verifica che… |
|---|---|---|
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che l'altro server restituisce per quell'altezza (`blockchain.block.header`) abbia lo stesso hash |
| **[ELC-16]** | togliere dal saldo una moneta sparita | l'altro server non elenchi più la moneta tra quelle non spese |
| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0) |
*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
*Test:* `tests/unit/test_electrum_listener.py`
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
stesso quorum. Le conseguenze di una verifica non riuscita:
- un accredito non verificato è solo **rimandato**, non perso: la moneta viene
riproposta al controllo successivo
([DEP-04](registrazione-e-depositi.md#deposito));
- una moneta sparita non verificata resta nel saldo
([DEP-07](registrazione-e-depositi.md#deposito));
- un blocco non verificato fa attendere il blocco seguente, e lo si registra
in audit (`draw_header_corroboration_failed`,
[RND-16](ciclo-di-vita-del-round.md#3--estrazione)).
Il reintegro di una moneta ricomparsa
([DEP-08](registrazione-e-depositi.md#deposito)) e la lettura delle monete del
montepremi per il pagamento si basano invece sul solo server attivo.
## Riconnessione
- **[ELC-18]** Alla riconnessione il listener si reiscrive agli indirizzi di
tutti gli utenti (letti dal database) e ne aggiorna le monete, in
un'attività separata, al massimo 20 alla volta (`_RESUBSCRIBE_CONCURRENCY`):
in questo modo l'altezza della catena — e con essa un'eventuale estrazione
in corso — continua ad aggiornarsi durante la reiscrizione. Un utente la cui
reiscrizione fallisce viene registrato nei log e saltato; lo riprende il
giro periodico di `DepositReconciler`
([DEP-10](registrazione-e-depositi.md#dep-10)). Se la sessione termina,
la reiscrizione in corso viene interrotta.
*Codice:* `app/electrum/listener.py:ElectrumListener._subscribe_all_users`, `app/electrum/listener.py:ElectrumListener._run_once` ·
*Test:* `tests/unit/test_electrum_listener.py`
Lo stato della connessione e l'altezza corrente sono visibili nella barra di
stato della catena di `/` (da `chain_tip_height` di `GET /rounds/current`) e
nell'indicatore Electrum di `/admin`.
+944
View File
@@ -0,0 +1,944 @@
# Decisioni di progetto e limiti noti
Questo documento raccoglie due cose che non si ricavano leggendo un singolo
file del codice:
- **le decisioni di progetto (`DEC`)**: scelte deliberate, con il motivo, le
alternative scartate dove sono note e le conseguenze. Chi vuole cambiarne
una deve saperlo prima di toccare il codice, perché altri requisiti ne
dipendono;
- **i limiti noti (`LIM`)**: difetti di copertura o di robustezza accettati
consapevolmente, con l'impatto, ciò che oggi li attenua e cosa servirebbe
per chiuderli.
Un limite noto **non è un bug**: un bug è un comportamento che contraddice la
specifica e prende un numero `B-nn` ([storico-bug.md](storico-bug.md)). Se si
decide di chiudere un limite, si aggiorna prima la voce `LIM` qui (o la si
barra come rimossa) e poi il requisito del documento di dominio, secondo
[processo-di-modifica.md](processo-di-modifica.md).
Ogni voce rimanda al codice che la incarna. I dettagli di comportamento stanno
nei documenti di dominio, a cui si rimanda invece di ripeterli.
---
## Parte 1 — Decisioni di progetto (DEC)
Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*,
*Alternative scartate* (dove note), *Conseguenze*, e i riferimenti.
### Custodia, chiavi e indirizzi
- **[DEC-01]** **La piattaforma è custodiale: tutte le chiavi sono generate e
tenute dal server.** Una sola master xprv, cifrata con Fernet a riposo; la
chiave di cifratura sta in una variabile d'ambiente (`XPRV_ENCRYPTION_KEY`),
mai nel database né in git. L'utente non controlla nulla finché non preleva.
- *Motivazione:* un'esperienza da «conto» (registrazione con username e
password, scommessa con un clic) senza portafoglio lato utente, firme o
PSBT da gestire nel browser.
- *Alternative scartate:* portafoglio non custodiale con firma lato client.
- *Conseguenze:* la compromissione del server (file cifrato + variabile
d'ambiente) espone tutti i fondi; da qui l'esportazione WIF accettata
([DEC-39]), l'assenza di recupero password ([DEC-37]) e il modello di
minaccia di [sicurezza.md](sicurezza.md).
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
*Test:* `tests/unit/test_hd.py`
- **[DEC-02]** **Un indirizzo per utente, permanente, per tutti gli usi.**
Alla registrazione il server deriva un indirizzo P2WPKH BIP84
(`m/84'/746'/0'/0/<indice>`, un indice per utente, assegnato come massimo
esistente + 1). Lo stesso indirizzo è indirizzo di deposito, riceve le
vincite e riceve il resto di ogni scommessa e prelievo. Non esiste un
«indirizzo vincitore» separato.
- *Motivazione:* un solo indirizzo da osservare per utente, un solo saldo da
ricostruire (`utxo_events`), e le vincite accreditate dal normale percorso
dei depositi senza logica dedicata.
- *Conseguenze:* nessuna privacy fra depositi, vincite e resti; un prelievo
verso il proprio indirizzo è rifiutato (renderebbe indistinguibili
destinatario e resto); il pagamento di un round si considera accreditato
quando il listener vede la moneta sull'indirizzo del vincitore
(vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)).
*Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_hd.py`, `tests/unit/test_users.py`
- **[DEC-03]** **Il montepremi è un unico indirizzo, derivato dalla stessa
master key sul ramo `1/0` dell'account** (il ramo `0` è riservato agli
utenti). Tutte le scommesse di tutti i round arrivano lì e il pagamento è
firmato con quella chiave.
- *Motivazione:* nessun segreto aggiuntivo da custodire.
- *Conseguenze:* i round condividono lo stesso insieme di monete; i resti di
polvere e le monete avanzate si accumulano sull'indirizzo senza
consolidamento automatico ([LIM-17]).
*Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` ·
*Test:* `tests/unit/test_pending_address.py`
- **[DEC-04]** **Accesso alla rete solo via protocollo Electrum**, senza nodo
completo né P2P. Una sola connessione attiva serve tutto (accrediti, invii,
conferme, altezza della catena).
- *Motivazione:* nessun nodo da installare e sincronizzare.
- *Conseguenze:* la connessione è il principale punto singolo di guasto e il
principale rischio di integrità; da qui le difese di [DEC-07] e
[DEC-26] e i limiti [LIM-11], [LIM-12], [LIM-14]. Dettagli in
[connessione-electrum.md](connessione-electrum.md).
*Codice:* `app/electrum/client.py:ElectrumClient` · `app/electrum/listener.py:ElectrumListener` · `app/main.py:lifespan`
- **[DEC-05]** **Solo indirizzi bech32 PLM (`plm1…`) come destinazione.** Un
prelievo verso un indirizzo legacy (`P…`) o di un'altra catena (`bc1…`) è
rifiutato; lo stesso controllo vale per `fee_address` e per `/qr/{address}`.
- *Motivazione:* un indirizzo di un'altra catena si interpreta comunque come
witness program valido, quindi la transazione si costruirebbe e i fondi
sarebbero irrecuperabili.
*Codice:* `app/wallet/address.py:is_valid_plm_address` · `app/withdrawals/service.py:request_withdrawal` · `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_non_plm_address`
### Conferme e fiducia nella rete
- **[DEC-06]** **Una conferma per ogni tipo di transazione** (deposito,
scommessa, pagamento, prelievo), senza logica di rollback in caso di reorg.
Non si introducono soglie diverse (3, 6…) senza una decisione esplicita.
- *Motivazione:* blocchi da 120s e importi da lotteria; un round
attende già tre blocchi in sequenza (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)).
- *Conseguenze:* rischio di reorg a 1 conferma accettato ([LIM-10]).
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
- **[DEC-07]** **Ogni decisione che sposta denaro in base a una risposta di
rete è corroborata da un quorum degli altri server configurati**: il blocco
che semina l'estrazione, una moneta data per spesa all'esterno, un nuovo
deposito da accreditare. Si interrogano solo gli *altri* server, mai quello
attivo (che è ciò che un attaccante controllerebbe); serve la maggioranza
stretta di chi risponde. Senza server di riserva configurati la
corroborazione passa sempre; se nessuno risponde, fallisce.
- *Motivazione:* un solo server ostile non deve poter scegliere il
vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59). I saldi si
muovono in entrambe le direzioni, quindi entrambe richiedono il quorum.
- *Conseguenze:* una corroborazione fallita **ritarda**, non perde: il
deposito è riproposto al refresh successivo, l'estrazione attende un blocco
ulteriore. Lista vuota di server di riserva = fiducia nel solo server
primario ([LIM-11]). Non tutte le letture sono corroborate ([LIM-12]).
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.corroborate_header` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
*Test:* `tests/unit/test_electrum_listener.py`
### Configurazione e ambiente
- **[DEC-08]** **I parametri operativi stanno nel database, i segreti
nell'ambiente.** Ogni parametro di business (costo della scommessa, durata e
pausa del round, animazione, fee rate, timeout RBF, `fee_address`, pausa) è
una colonna dell'unica riga di `round_config`, modificabile a caldo da
`/admin`; i valori iniziali sono i default delle colonne di `RoundConfig`,
non `app/config.py`. In `.env` restano solo segreti e collegamenti
(master key, JWT, host Electrum, token admin, URL del database).
- *Motivazione:* regolare la lotteria senza ridistribuire né riavviare;
i segreti richiedono comunque un riavvio e non vanno scambiati a caldo.
- *Conseguenze:* ogni modifica è validata con limiti espliciti ([DEC-09]) e
registrata in audit come `config_updated` con prima/dopo.
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · `app/config.py:Settings` · `app/api/routes/admin.py:update_config` ·
*Test:* `tests/unit/test_admin.py::test_admin_reads_and_updates_config`, `tests/unit/test_admin.py::test_config_update_is_audit_logged`
- **[DEC-09]** **Le modifiche di configurazione sono validate, non fidate.**
Ogni campo numerico ha limiti (ad esempio `fee_rate_sat_vb` fra 1 e
`MAX_FEE_RATE_SAT_VB`, `round_duration_seconds` almeno 30) e `fee_address`
deve essere un indirizzo PLM valido. `paused` non è modificabile da
`PUT /admin/config`.
- *Motivazione:* un fee rate a 0 produce transazioni che nessun nodo inoltra,
una durata a 0 fa scadere il round appena aperto, un `fee_address` estraneo
manda la commissione in uno script irrecuperabile (B-05).
*Codice:* `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py::test_config_rejects_unusable_values`, `tests/unit/test_admin.py::test_pause_cannot_be_toggled_through_the_config_endpoint`
- **[DEC-10]** **I tempi del round sono fotografati all'apertura.** Durata e
pausa successiva vengono copiate sulla riga del round quando si apre; una
modifica vale dal round successivo, mai per quello in corso. La pausa fra
due round è quella annunciata dal round appena chiuso.
- *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e
spostava il conto alla rovescia di tutti i client (B-61).
- *Conseguenze:* **solo i tempi** sono fotografati. Costo della scommessa,
fee rate e `fee_address` sono letti dal vivo ([LIM-16]).
*Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
- **[DEC-11]** **Il server rifiuta di partire con segreti inadeguati**:
`JWT_SECRET` sotto i 32 caratteri o `XPRV_ENCRYPTION_KEY` vuota fermano
l'avvio. Un `ADMIN_TOKEN` vuoto invece non è fatale: il pannello resta
chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
`Settings`.
- *Motivazione:* evitare un container che sembra sano e si rompe al primo
utente reale; tenerlo fuori da `Settings` permette a import e test di
funzionare senza `.env`.
- *Alternative scartate:* validatore Pydantic su `Settings` (avrebbe fatto
fallire la raccolta dei test su un clone pulito, B-15).
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_config.py`
- **[DEC-12]** **Il server gira solo in Docker, in sviluppo come in
produzione**, dietro Caddy (TLS automatico, certificato autofirmato su
`localhost`). Il container rifiuta di partire senza il file della master
key, applica le migrazioni Alembic all'avvio e si riavvia da solo
(`restart: unless-stopped`). Il venv serve solo per test, migrazioni e
script della chiave.
- *Motivazione:* un solo modo di eseguire il server, identico ovunque.
- *Conseguenze:* dopo un crash `closing` e `paying_out` riprendono da soli,
`drawing` no ([LIM-01]). Procedure in [operativita.md](operativita.md).
*Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
- **[DEC-13]** **Un solo processo, un solo file SQLite** (modalità WAL con
`busy_timeout`). Canale SSE, blocchi per-utente, limitatori di tentativi e
cache sono in memoria.
- *Motivazione:* semplicità a scala di ~100 utenti concorrenti.
- *Alternative scartate:* Redis o un database con lock condivisi, da non
introdurre in via speculativa.
- *Conseguenze:* nessuna distribuzione su più worker ([LIM-18]). L'unico
invariante che non dipende dal processo singolo è «al più un round
attivo», garantito da un indice ([DEC-22]).
*Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_db_base.py`
### Economia del gioco
- **[DEC-14]** **Ripartizione fissa 70% al vincitore / 30% alla piattaforma**,
con l'arrotondamento a favore delle commissioni. È nel codice, non nella
configurazione: cambiarla è una modifica del software.
- *Motivazione:* una regola economica di fondo non deve poter cambiare con un
clic dal pannello. Un'unica funzione alimenta sia il montepremi mostrato sia
il pagamento, così i due non possono divergere.
*Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
- **[DEC-15]** **Biglietto a prezzo fisso, una scommessa per utente per round,
probabilità uguale per tutti** indipendentemente dall'importo effettivo.
Nessun deposito minimo.
- *Conseguenze:* l'estrazione sceglie fra indirizzi, non pesa gli importi.
Poiché il costo è letto dal vivo, una modifica a metà round crea poste
diverse con la stessa probabilità ([LIM-16]).
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round`
- **[DEC-16]** **La commissione di rete si toglie dall'importo che si
muove**, non si aggiunge sopra: per una scommessa arriva al montepremi il
costo meno la commissione, per un prelievo arriva a destinazione l'importo
meno la commissione.
- *Conseguenze:* il montepremi si calcola sommando gli importi effettivi
delle scommesse, non moltiplicando il numero di giocatori per il costo
(B-11); il prelievo registra sia l'importo richiesto sia quello inviato.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_deducts_fee_from_amount_not_change`
- **[DEC-17]** **La commissione del pagamento esce dalla quota del
vincitore**; il 30% della piattaforma resta intatto.
- *Conseguenze:* il montepremi annunciato (il 70% delle scommesse
confermate) supera di poco quanto il vincitore riceve davvero; la
differenza, la commissione del pagamento, non è nota finché il pagamento non
è costruito.
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
- **[DEC-18]** **L'accelerazione RBF la paga chi riceve il resto**: l'utente
per scommesse e prelievi, il montepremi per i pagamenti. Le uscite verso la
controparte (montepremi, indirizzo esterno, vincitore, `fee_address`) non si
toccano mai; si riduce solo il resto del mittente.
- *Conseguenze:* una transazione senza resto non si può accelerare
([LIM-02]).
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_find_change_output` ·
*Test:* `tests/unit/test_broadcast.py`
- **[DEC-19]** **Ogni transazione utente conserva un'uscita di resto di almeno
`DUST_LIMIT_SATS` (294 sat)**, così resta accelerabile. Chi preleva tutto
sposta il saldo meno 294 sat, che restano sul conto (e la commissione di rete
esce, come sempre, dall'importo spostato); una scommessa da un saldo
pari al costo è rifiutata con `balance_leaves_no_change`, perché il prezzo
fisso non si può ridurre.
- *Motivazione:* un prelievo totale produceva una transazione a un'uscita
che nessun RBF poteva salvare (B-62). Aggiungere monete non avrebbe
aiutato: la transazione le spendeva già tutte.
- *Conseguenze:* **il pagamento del round fa eccezione**: un resto del
montepremi sotto la soglia va alla commissione e, nel caso normale, il
pagamento non ha resto ([LIM-02]).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
- **[DEC-20]** **Il prelievo minimo è il costo corrente della scommessa**, senza
un campo di configurazione separato.
- *Motivazione:* un parametro in meno da tenere coerente.
- *Conseguenze:* alzare il costo della scommessa alza anche il prelievo
minimo, con effetto immediato.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_amount_below_minimum`
- **[DEC-21]** **Tre limiti distinti sul numero di monete e di giocatori:**
| Costante | Valore | Protegge da |
|---|---|---|
| `MAX_TX_INPUTS` | 50 | una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, `too_many_inputs` |
| `MAX_PAYOUT_TX_INPUTS` | 500 | un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine |
| `MAX_PARTICIPANTS_PER_ROUND` | 400 | un round impagabile: il 401° giocatore è rifiutato con `round_full` **prima** che si muova denaro |
- *Motivazione:* il pagamento spende una moneta del montepremi per scommessa;
usare per esso il limite utente rendeva impagabile ogni round oltre ~50
giocatori e fermava la lotteria (B-52). I 100 di differenza fra 400 e 500
lasciano spazio alle monete avanzate da round precedenti.
- *Conseguenze:* «ogni round si può pagare» è un invariante verificato
all'ingresso, non una scoperta al momento del pagamento. Il conteggio dei
partecipanti include quelli non ancora confermati (direzione prudente).
*Codice:* `app/wallet/psbt_builder.py:select_utxos` · `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`, `tests/unit/test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_stays_well_inside_relay_limits`
### Round ed estrazione
- **[DEC-22]** **Al più un round attivo, come invariante del database.** Un
indice unico su un'espressione costante, limitato agli stati attivi, fa
fallire in modo pulito il secondo inserimento concorrente; chi perde adotta
il round del vincitore (al massimo 3 tentativi). I round non si
sovrappongono mai: il successivo si apre solo quando il precedente è
`closed`.
- *Alternative scartate:* rendere lo scheduler l'unico autore dei round (la
prima scommessa dopo una pausa non avrebbe potuto aprirne uno, B-09).
*Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` ·
*Test:* `tests/unit/test_rounds_service.py::test_the_database_refuses_a_second_active_round`, `tests/unit/test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round`
- **[DEC-23]** **Il limite per scommettere è la scadenza del round stesso, non
il suo stato nel database**, e la chiusura «a semaforo giallo» aspetta che
ogni scommessa già inviata sia confermata prima di estrarre. L'iscrizione è
salvata con un compare-and-set sulla riga del round.
- *Motivazione:* lo scheduler controlla ogni 5s, quindi lo stato può restare
`open` oltre la scadenza; senza il compare-and-set una scommessa poteva
finire nel montepremi ed essere esclusa dall'estrazione (B-53).
- *Conseguenze:* l'attesa in `closing` è limitata solo dall'abbandono di una
scommessa che non conferma mai ([LIM-03]). Dettagli in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/service.py:round_accepts_bets` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `tests/unit/test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check`
- **[DEC-24]** **L'algoritmo di estrazione è un componente sostituibile, non il
disegno definitivo**: seme = hash del primo blocco confermato dopo la
chiusura, indice = seme modulo numero di partecipanti confermati, ordinati per
orario di invio della scommessa. Non si deve architettare il resto del
sistema attorno alla sua forma attuale.
- *Motivazione:* semplice e deterministico, pensato per essere rimpiazzato.
- *Conseguenze:* vedi [LIM-13] per i limiti di questa forma (entropia
dal minatore, verificabilità, pareggi).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_draw.py`
- **[DEC-25]** **Il blocco che semina l'estrazione deve essere successivo alla
chiusura e corroborato.** L'attesa del blocco non ha timeout (l'entropia
dipende davvero da un blocco futuro) ma è osservabile (`draw_stalled`,
`draw_waiting_since`). Un'altezza `0` alla chiusura significa «sconosciuta»:
la prima altezza appresa diventa la base e si attende un blocco ancora
successivo.
- *Motivazione:* un hash già pubblico mentre le scommesse erano aperte
renderebbe il vincitore prevedibile (B-63); un header fabbricato
sceglierebbe il vincitore (B-28).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
*Test:* `tests/unit/test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`, `tests/unit/test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration`
- **[DEC-26]** **Validazione minima degli header, senza catena completa.** Un
header deve rispettare il bersaglio di difficoltà che dichiara e, se avanza
di un solo blocco, concatenarsi alla punta attuale; la punta non regredisce
mai; un secondo header alla stessa altezza o un header senza `hex` vengono
ignorati senza chiudere la sessione.
- *Motivazione:* bloccare header inventati senza scaricare e validare la
storia delle difficoltà; lasciare al quorum ([DEC-07]) il caso residuo.
- *Alternative scartate:* validazione completa della catena degli header
(fuori scopo per la v1).
- *Conseguenze:* [LIM-14].
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` ·
*Test:* `tests/unit/test_electrum_listener.py::test_apply_header_rejects_one_that_does_not_chain_from_the_tip`, `tests/unit/test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height`
- **[DEC-27]** **Pausa di cortesia fra un round e l'altro**
(`round_cooldown_seconds`, 30s), contata dalla **conferma del pagamento**, non
dall'inizio della chiusura, così i giocatori vedono l'esito.
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_during_cooldown`
- **[DEC-28]** **La pausa per manutenzione ferma solo il round successivo.**
È un'azione esplicita dell'operatore con endpoint propri e registrati in
audit (`lottery_paused`, `lottery_resumed`), non un campo di configurazione;
il round in corso chiude, estrae e paga normalmente.
- *Motivazione:* sospendere a metà un round con denaro nel montepremi
creerebbe proprio lo stato bloccato che la pausa vuole evitare.
*Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_paused`, `tests/unit/test_rounds_service.py::test_pause_does_not_interrupt_a_round_in_progress`, `tests/unit/test_admin.py::test_admin_can_pause_and_resume_the_lottery`
- **[DEC-29]** **Senza `fee_address` non si apre nessun round.** Il campo non ha
valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun
round si apre (`lottery_configured = false` su `GET /rounds/current`, avviso
in `/admin`); un round già in corso invece chiude, estrae e paga anche se
l'indirizzo viene cancellato. Qualunque altra condizione che renderebbe un
round impagabile va aggiunta nello stesso punto.
- *Motivazione:* un round aperto senza `fee_address` raccoglieva scommesse e
solo al pagamento scopriva di non poter essere pagato (B-66).
*Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address`
- **[DEC-30]** **Il montepremi annunciato è quello che sarà pagato**: il 70%
delle sole scommesse confermate, affiancato dai valori «in attesa» che
includono le scommesse in volo (valori inclusivi, non differenze).
- *Motivazione:* mai annunciare più di quanto l'estrazione e il pagamento
considereranno (B-65), senza nascondere al giocatore la propria scommessa
appena fatta.
*Codice:* `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid`
### Denaro in movimento
- **[DEC-31]** **Tutto ciò che spende denaro si scrive prima di inviarlo** (stato
`building`, monete già segnate come spese) e si risolve contro la catena
dopo; nessuna sessione del database resta aperta durante una chiamata di
rete. Un invio rifiutato annulla tutto e risponde **502**.
- *Motivazione:* un crash fra invio e salvataggio lasciava monete spese in
catena senza traccia (B-08, B-25). Dettagli in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
- **[DEC-32]** **Il pagamento si ritenta da solo, mai due volte**: un tentativo
ogni 60s, usando l'ultimo `payout_failed` in audit come orologio; prima di
costruire si rifiuta se esiste già un pagamento `building`/`pending` per il
round, e si escludono le monete impegnate da pagamenti non risolti.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py::test_trigger_payout_skips_when_already_in_flight`, `tests/unit/test_scheduler.py::test_tick_throttles_retry_after_a_recent_payout_failure`, `tests/unit/test_scheduler.py::test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`
- **[DEC-33]** **Scommessa e prelievo si escludono solo tramite il blocco
per-utente** sulla *costruzione*: un prelievo è accettato anche mentre una
scommessa attende conferma, purché le monete confermate e libere lo coprano.
- *Motivazione:* il rischio è che scelgano la stessa moneta, già escluso dal
blocco e dal fatto che la selezione salta le monete segnate come spese;
congelare il resto del saldo non aggiungerebbe sicurezza (B-70).
*Codice:* `app/tx/locks.py:UserLocks` · `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`
- **[DEC-34]** **Saldo spendibile e saldo mostrato sono due numeri distinti.**
Si spende solo il confermato (`balance_sats`); si mostra quello che include i
resti ancora in volo (`pending_balance_sats`, in ambra finché non conferma).
Un prelievo coperto solo dal secondo riceve `balance_pending_confirmation`.
- *Motivazione:* la selezione a monete intere sottrae l'intera moneta al
saldo confermato già all'invio, molto più di quanto si stia spostando.
*Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/wallet/balance.py:recompute_balance` · `app/api/routes/users.py:me` ·
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance`
- **[DEC-35]** **Aritmetica solo in satoshi interi**, anche per i valori letti
dalla rete: l'importo di una moneta spesa si ricava dalla transazione grezza,
non dal valore decimale restituito dal server.
*Codice:* `app/tx/broadcast.py:_prevout_amount` · `app/wallet/psbt_builder.py:estimate_vsize` ·
*Test:* `tests/unit/test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls`
### Account, amministrazione e sicurezza
- **[DEC-36]** **Password con Argon2, sessioni con JWT HS256 da 24 ore,
revocabili.** Il token porta la versione dell'account (`tv`); cambio password
e reset admin la incrementano e invalidano tutte le sessioni precedenti. Un
token senza `tv` vale come versione 0, così il rilascio non ha disconnesso
nessuno. Argon2 gira fuori dall'event loop.
- *Motivazione:* un reset per account compromesso deve buttare fuori subito
l'attaccante (B-34); Argon2 inline bloccava l'intero processo, attività in
background comprese (B-55).
*Codice:* `app/auth/security.py:create_access_token` · `app/auth/security.py:decode_access_token` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:verify_password_async` ·
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one`
- **[DEC-37]** **Nessun recupero password, solo reset.** Non si raccoglie
nessuna email. L'utente autenticato può *cambiare* la password fornendo
quella attuale; chi l'ha persa si rivolge all'operatore, che con
`POST /admin/users/{id}/reset-password` ne genera una casuale, mostrata una
volta sola e registrata in audit (`admin_password_reset`).
*Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py::test_change_password_requires_current_password`, `tests/unit/test_admin.py::test_admin_resets_user_password`
- **[DEC-38]** **Gli username sono un unico spazio di nomi senza distinzione fra
maiuscole e minuscole**, garantito da un indice unico su `lower(username)`;
il nome resta salvato come digitato (è ciò che mostrano `/admin` e l'audit).
Sono ammessi solo caratteri ASCII `[A-Za-z0-9_.-]`, 3–32. La migrazione si
rifiuta di partire se due account esistenti differiscono solo per le
maiuscole, invece di sceglierne uno da rinominare: entrambi potrebbero avere
fondi.
- *Motivazione:* «Bob» e «bob» erano due account che condividevano il
contatore dei tentativi e un comodo vettore di impersonazione (B-57).
*Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` ·
*Test:* `tests/unit/test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `tests/unit/test_auth.py::test_the_database_itself_rejects_a_case_variant`
- **[DEC-39]** **L'esportazione della chiave privata WIF di un utente è
voluta, non una vulnerabilità.** Il server possiede già la master key:
l'endpoint espone via API solo ciò che un operatore potrebbe comunque
ottenere con uno script (ad esempio per recuperare fondi bloccati). Ogni
accesso scrive `admin_privkey_accessed`, e quella registrazione non va tolta.
*Codice:* `app/api/routes/admin.py:user_privkey` · `app/wallet/hd.py:derive_user_wif` ·
*Test:* `tests/unit/test_admin.py::test_admin_exports_user_privkey`
- **[DEC-40]** **L'amministrazione usa un unico token condiviso**
(`X-Admin-Token` confrontato a tempo costante sui byte UTF-8 con
`ADMIN_TOKEN`), non un login per operatore. `/admin` mostra una schermata di
inserimento del token, **non è collegato da `/`** (né `/` da `/admin`) ed è in
italiano, non tradotto.
- *Conseguenze:* l'audit registra *cosa* è cambiato, mai *chi* l'ha fatto
([LIM-06]).
*Codice:* `app/api/routes/admin.py:require_admin` · `app/main.py:admin_panel` · `app/static/admin.html` ·
*Test:* `tests/unit/test_admin.py::test_admin_requires_token`, `tests/unit/test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500`
- **[DEC-41]** **Limitazione dei tentativi solo su login e registrazione**, in
memoria. Il login usa un ritardo esponenziale per username (dopo 5 errori) e
per IP (dopo 20, soglia più alta per non punire una rete condivisa); un
login riuscito azzera solo il contatore dello username. La registrazione usa
invece una **quota** di 5 account per IP all'ora, consumata solo dagli
account creati davvero. I contatori sono limitati in numero.
- *Motivazione:* indovinare una password e creare account in massa sono due
problemi diversi (B-33, B-58); il ritardo punisce i tentativi, la quota
limita la produzione.
- *Conseguenze:* nessun limite su scommesse, prelievi, admin, cambio
password ([LIM-07]).
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
- **[DEC-42]** **L'IP del client è l'ultimo elemento di `X-Forwarded-For`**, e
Caddy sovrascrive l'header con il vero indirizzo del peer. Un solo proxy
fidato sta davanti all'applicazione, che non è esposta direttamente.
- *Motivazione:* il primo elemento è scelto dal client; leggerlo rendeva
aggirabile ogni controllo per IP (B-54). Basta una delle due misure.
*Codice:* `app/api/client_ip.py:client_ip` · `Caddyfile` ·
*Test:* `tests/unit/test_client_ip.py`, `tests/unit/test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer`
- **[DEC-43]** **Header di sicurezza nel proxy, con `'unsafe-inline'`
accettato**: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy` e una
CSP `default-src 'self'` più Google Fonts. Script e stili inline restano
permessi perché entrambe le SPA usano `onclick` e `style=""`; rimuoverli è un
refactoring a parte. La documentazione automatica dell'API (`/docs`,
`/redoc`, `/openapi.json`) è spenta per default (`ENABLE_API_DOCS`).
- *Conseguenze:* [LIM-09].
*Codice:* `Caddyfile` · `app/main.py` · `app/config.py:Settings` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`, `tests/unit/test_docs_disabled.py`
- **[DEC-44]** **`/qr/{address}` è pubblico ma limitato**: nessuna
autenticazione (lo usa un `<img>`, che non può portare il token), ma
l'indirizzo è validato davvero, la lunghezza è limitata, la resa è in cache
(512 voci) e fuori dall'event loop.
*Codice:* `app/api/routes/qr.py:address_qr` ·
*Test:* `tests/unit/test_qr.py`
- **[DEC-45]** **Segnalazioni di bug aperte a tutti, triage in admin.** Si può
inviare una segnalazione da autenticati (viene attribuita all'utente) o no;
il testo va scritto in inglese, e la pagina lo dice nella lingua del
visitatore. Gli stati sono `open` → `read` → `resolved`, e ogni cambio reale
di stato è registrato in audit.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report` · `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_bug_reports.py`
### Interfacce, API e lingue
- **[DEC-46]** **Il canale SSE è un'aggiunta al polling, non un sostituto.**
Nessun contenuto e nessuna autenticazione: è solo un «è cambiato qualcosa,
ricarica»; la personalizzazione resta negli endpoint REST autenticati. Un
unico segnale per tutti (non per utente), una coda da 1 elemento per client
così le notifiche ridondanti si fondono, tetto di 500 connessioni globali e 5
per IP (oltre, si chiude la connessione più vecchia dello stesso IP). Le
pagine ricaricano anche all'evento `open` di ogni riconnessione.
- *Alternative scartate:* canale per utente (servirebbe autenticazione sul
flusso e sapere a chi interessa ogni evento); Redis pub/sub (solo quando ci
saranno più processi).
- *Conseguenze:* se il canale cade o è bloccato, le pagine continuano a
funzionare con il solo polling.
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` · `app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[DEC-47]** **L'API parla una sola lingua e risponde con codici.** Ogni
errore rivolto all'utente ha `detail = {code, message, params}`: `message` è
in inglese per chi non usa la dashboard, `code` è ciò che il frontend traduce
(`error.<code>`), `params` porta i valori (gli importi come `*_sats`). Anche
l'errore 500 generico ha questa forma (`internal_error`) e non espone il
testo dell'eccezione.
- *Conseguenze:* aggiungere un errore significa dargli un codice e
aggiungere `error.<code>` in tutte le 7 lingue (vedi [api.md](api.md)).
*Codice:* `app/api/errors.py:ApiError` · `app/api/errors.py:http_error` · `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape`
- **[DEC-48]** **Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue**
(en, it, es, fr, de, ru, zh) da un'unica tabella piatta, senza build né
download. Lingua scelta da `localStorage.plm_lang`, poi dal browser, poi
inglese; nessun'altra lingua di riserva oltre l'inglese. Il selettore di `/`
sta nella barra di stato della catena, non nella barra di navigazione
(nascosta prima del login). Un elemento è tradotto *o* dagli attributi
`data-i18n` *o* dal codice con `t()`, mai da entrambi. Tutte le lingue devono
avere esattamente le stesse chiavi.
- *Conseguenze:* la parità delle chiavi non è verificata da un test
([LIM-21]).
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:applyStaticTranslations` · `app/static/i18n.js:setLanguage` · `app/static/app.js:onLanguageChange`
- **[DEC-49]** **Il pannello `/admin` e la pagina `/guida` non sono tradotti**
(sono in italiano, per l'operatore).
*Codice:* `app/static/admin.html` · `app/static/guida.html`
- **[DEC-50]** **Frontend statici, senza framework né build**: due SPA servite
direttamente da FastAPI con `Cache-Control: no-store`, che parlano con la
stessa API JSON (nessuna separazione fra API utente e admin oltre al
controllo del token).
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
---
## Parte 2 — Limiti noti (LIM)
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
chiuderlo*, e il codice coinvolto.
### Quali limiti bloccano il funzionamento non presidiato
| Limite | Blocca il non presidiato? |
|---|---|
| [LIM-01] `drawing` non riprende dopo un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco, e l'ultimo prerequisito dichiarato |
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** |
| [LIM-03] Attesa in `closing` fino a 6 ore | **Sì, in caso di congestione** (conseguenza di LIM-02) |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** — da fare prima di lasciare il sistema da solo |
| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio |
| tutti gli altri | No |
### Robustezza del ciclo di vita
- **[LIM-01]** **Lo stato `drawing` non riprende dopo un riavvio.** Lo
scheduler gestisce `open`, `closing` e `paying_out` a ogni giro; l'attesa del
blocco (`_wait_for_next_block`) vive solo dentro la chiamata a
`_close_and_draw` che l'ha avviata, con la lista dei partecipanti in memoria.
Se il processo si ferma in quel momento, nessuno la riprende. Il terzo audit
(2026-08-03) lo ha ribadito come problema **critico**, senza numerarlo
perché già elencato qui.
- *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro;
poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si
ferma**. Con `restart: unless-stopped` un crash in quella finestra è
esattamente il caso che non si ripara da solo.
- *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento
`draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su
`GET /rounds/current`. Il recupero è manuale (vedi
[operativita.md](operativita.md)).
- *Per chiuderlo:* salvare sulla riga del round l'altezza di base
dell'estrazione al passaggio in `drawing` e, in `_tick`, rientrare in
`_wait_for_next_block` per un round `drawing` senza un'attesa in corso,
ricostruendo i partecipanti dal database (sono già tutti `confirmed` e
ordinabili per `broadcast_at`).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
sull'intera dimensione della transazione (circa 141 sat per una transazione
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
al minimo di 294 sat ([DEC-19]) già il primo aumento produce un'uscita sotto
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
round, nel caso normale, non ha alcun resto** (spende esattamente il
montepremi del round, e un resto sotto soglia va alla commissione), quindi
non è accelerabile affatto.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
ciò che il server **non conosce**, una transazione ferma ma presente in
mempool non viene mai abbandonata: una scommessa così blocca il round in
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
registra un errore ogni 30s.
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
sparisce dalla rete; ritentativo automatico del pagamento dopo un
abbandono.
- *Per chiuderlo:* aggiungere ingressi a una transazione da accelerare
(dall'indirizzo del mittente), oppure CPFP sul resto; per il pagamento,
garantire un'uscita di resto verso il montepremi come per le transazioni
utente; in ogni caso rifiutare un aumento che lasci il resto sotto
`DUST_LIMIT_SATS`. Il docstring di `bump_fee` cita ancora come esempio «una scommessa
senza resto», caso che dopo B-62 non esiste più.
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once`
- **[LIM-03]** **L'attesa in `closing` non ha un limite proprio.** Il round
aspetta ogni scommessa `building`/`broadcast`; a porre fine all'attesa di una
scommessa che non conferma è solo il riconciliatore, dopo 6 ore dal primo
invio (e solo se la transazione è sparita dalla rete, [LIM-02]).
- *Impatto:* un round può restare chiuso ma non estratto per ore; nessun
nuovo round si apre nel frattempo.
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
«Transazioni pendenti» mostra la scommessa che trattiene il round.
- *Per chiuderlo:* una politica esplicita per le scommesse lente (per esempio
escluderle e rimborsarle dopo un tempo massimo), che è una modifica della
specifica del round, non una correzione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/tx/reconcile.py:_is_due`
- **[LIM-04]** **Prelievo e accelerazione RBF non sono mai stati inviati in una
rete reale.** Sono coperti da test unitari, rami di errore e annullamento
compresi; il resto del flusso (registrazione, deposito, scommessa, un ciclo
completo di round con pagamento) è stato verificato in mainnet.
- *Impatto:* un errore di costruzione o di firma specifico della rete reale
emergerebbe al primo prelievo di un utente vero.
- *Per chiuderlo:* un prelievo e un RBF reali in mainnet, con verifica
dell'aritmetica sulla transazione inviata.
*Codice:* `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee`
- **[LIM-05]** **Nessun test di integrazione con una connessione Electrum
vera.** `tests/integration/` è vuota; la verifica dal vivo è stata tutta
manuale (`scripts/electrum_smoke_test.py`, script ad hoc, transazioni reali).
- *Impatto:* le differenze di comportamento fra implementazioni di server
Electrum (formati di risposta, errori) emergono solo in produzione.
- *Per chiuderlo:* una suite su testnet o regtest, o contro un server
registrato.
*Codice:* `tests/integration/__init__.py` · `scripts/electrum_smoke_test.py`
### Sicurezza e amministrazione
- **[LIM-06]** **Un unico token admin condiviso, senza identità per
operatore.** Il registro di audit dice *cosa* è cambiato (per esempio
`config_updated` con prima/dopo) ma non *chi*. Il token dà accesso a elenco
utenti, esportazione delle chiavi private, reset delle password e storico.
Non c'è limite ai tentativi sul token.
- *Impatto:* una fuga del token ha il raggio d'azione più ampio del sistema,
ed è impossibile attribuire un'azione a una persona.
- *Mitigazione attuale:* confronto a tempo costante, pannello chiuso se il
token è vuoto, ogni azione sensibile in audit, `/admin` non collegato dalla
pagina pubblica. Dettagli in [sicurezza.md](sicurezza.md).
- *Per chiuderlo:* account amministrativi individuali (con l'identità scritta
in ogni voce di audit) e limitazione dei tentativi sul token.
*Codice:* `app/api/routes/admin.py:require_admin` · `app/audit/log.py:write_audit_log`
- **[LIM-07]** **Nessuna limitazione di frequenza su scommesse, prelievi,
endpoint admin, cambio password e segnalazioni di bug.** Solo login e
registrazione sono limitati ([DEC-41]); il canale SSE ha tetti di
concorrenza (500 globali, 5 per IP), non di frequenza.
- *Impatto:* un utente autenticato può martellare scommesse e prelievi (il
blocco per-utente li serializza, non li limita); il cambio password
permette di provare password attuali senza ritardi a chi possiede già un
token valido; le segnalazioni anonime possono riempire il database.
- *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint
interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`.
*Codice:* `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report`
- **[LIM-08]** **Nessun registro di sicurezza lato utente.** Login, login
falliti e cambio password dell'utente non scrivono voci di audit (le scrive
solo il reset da parte dell'admin).
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
avvenuto l'accesso.
- *Per chiuderlo:* eventi di audit per questi casi (vedi
[audit-log.md](audit-log.md)).
*Codice:* `app/auth/routes.py:login` · `app/api/routes/users.py:change_password`
- **[LIM-09]** **Il JWT sta in `localStorage` e la CSP ammette script inline.**
Una falla XSS permetterebbe di leggere il token.
- *Mitigazione attuale:* CSP `default-src 'self'`, `frame-ancestors 'none'`,
escape dei valori controllati dall'utente nelle pagine; sessioni revocabili
([DEC-36]).
- *Per chiuderlo:* togliere gestori e stili inline dalle SPA per poter
eliminare `'unsafe-inline'`, oppure spostare la sessione in un cookie
`HttpOnly`.
*Codice:* `Caddyfile` · `app/static/app.js`
### Fiducia nella rete
- **[LIM-10]** **Rischio di reorg a 1 conferma accettato, senza rollback.** Un
deposito accreditato a 1 conferma può sparire con una riorganizzazione; la
punta della catena non regredisce mai e un header concorrente alla stessa
altezza è ignorato.
- *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene,
previo quorum, segnata come spesa all'esterno e tolta dal saldo; se
riappare viene ripristinata. Una scommessa costruita su una moneta
scomparsa è rifiutata dalla rete e annullata.
- *Per chiuderlo:* soglie di conferma più alte (decisione esplicita,
[DEC-06]) o una gestione completa dei reorg.
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
- **[LIM-11]** **Senza server di riserva configurati la corroborazione passa
sempre.** Con `ELECTRUM_FALLBACK_SERVERS` vuoto il solo server primario decide
estrazione, spese esterne e accrediti. Anche con server di riserva, la
maggioranza è calcolata **su chi risponde**, fra server scelti dall'operatore.
- *Impatto:* chi controlla il server primario (o la maggioranza dei server
raggiungibili) può scegliere il vincitore o alterare i saldi mostrati.
- *Mitigazione attuale:* nessun server risponde → la corroborazione
**fallisce** (una rete irraggiungibile non prova nulla).
- *Per chiuderlo:* rendere obbligatorio un numero minimo di server di riserva,
o un quorum minimo di risposte.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal
solo server attivo: la conferma di scommesse, pagamenti e prelievi
(`poll_once`), la decisione del riconciliatore che una transazione «non
esiste più», l'elenco delle monete del montepremi usato per il pagamento, e il
ripristino di una moneta prima segnata come spesa all'esterno.
- *Impatto:* un server ostile potrebbe far considerare confermata una
scommessa che non lo è, o far abbandonare a torto una transazione viva
(liberandone le monete e, per un pagamento, azzerando `payout_txid`),
lasciando il database in disaccordo con la catena; il ripristino non
corroborato può reintegrare nel saldo una moneta davvero spesa.
- *Mitigazione attuale:* l'abbandono avviene solo dopo 6 ore per `pending`; un
invio basato su monete inesistenti è rifiutato dalla rete.
- *Per chiuderlo:* estendere il quorum di [DEC-07] a queste decisioni.
Il ripristino in particolare contraddice il principio «entrambe le
direzioni richiedono il quorum» ed è un candidato a un `B-nn`.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
- **[LIM-13]** **Limiti della forma attuale dell'estrazione.**
- *Entropia dal minatore:* chi mina il blocco può scegliere di non
pubblicarlo se l'esito non gli conviene, rinunciando alla ricompensa.
- *Verificabilità:* l'ordine dei partecipanti è l'orario di invio registrato
dal server, un dato non pubblico; un giocatore non può ricalcolare da solo
l'esito dai soli dati di catena (il commento di `draw_winner` dice il
contrario).
- *Pareggi:* due scommesse con lo stesso `broadcast_at` non hanno un criterio
secondario di ordinamento.
- *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti
introduce una distorsione trascurabile.
- *Per chiuderlo:* sostituire l'algoritmo ([DEC-24]) con uno che pubblichi
l'ordine dei partecipanti (o lo derivi da dati di catena) e un criterio di
pareggio totale.
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw`
- **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà
reale.** Un server può dichiarare un bersaglio facile e minare davvero un
header a quella difficoltà con hardware modesto; la concatenazione si
verifica solo per un avanzamento di un blocco esatto.
- *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione
([DEC-07], [DEC-25]).
- *Per chiuderlo:* validazione della catena degli header con le regole di
ricalcolo della difficoltà di PLM.
*Codice:* `app/rounds/draw.py:header_meets_its_own_target` · `app/electrum/listener.py:ElectrumListener._apply_header`
- **[LIM-15]** **La sottoscrizione di un nuovo indirizzo è a sforzo
singolo.** Se fallisce subito dopo la registrazione, l'indirizzo resta non
osservato fino alla riconnessione successiva o al giro di `DepositReconciler`
(300s, che attende prima di partire).
- *Impatto:* un deposito fatto subito dopo la registrazione può tardare fino
a cinque minuti.
*Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` · `app/deposits/reconcile.py:DepositReconciler`
### Configurazione ed economia
- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo
della scommessa, fee rate e `fee_address` sono letti dal vivo.
- *Impatto:* cambiare il costo a metà round fa pagare poste diverse a
giocatori con la stessa probabilità, e cambia subito il prelievo minimo;
cambiare `fee_address` a metà round devia la commissione del round in
corso (la modifica è comunque registrata in audit).
- *Per chiuderlo:* fotografare anche questi campi sulla riga del round, come
già si fa per durata e pausa ([DEC-10]).
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal`
- **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete
avanzate e resti si accumulano sull'unico indirizzo del montepremi; il
margine fra 400 partecipanti e 500 ingressi ([DEC-21]) si consuma nel tempo.
- *Impatto:* raggiunto il limite, il pagamento fallisce con
`too_many_inputs` e il round resta in `paying_out`, con un `payout_failed`
ogni 60s.
- *Per chiuderlo:* un'operazione di consolidamento (manuale o automatica) del
montepremi. Procedura in [operativita.md](operativita.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction`
### Prodotto e interfacce
- **[LIM-18]** **Il sistema presuppone un solo processo.** Canale SSE, blocchi
per-utente, limitatori di tentativi, cache dei QR e della chiave dell'account
sono in memoria.
- *Impatto:* con più worker, due costruzioni dello stesso utente potrebbero
scegliere le stesse monete, i limiti per IP si dividerebbero per worker e
gli eventi SSE arriverebbero solo ai client dello stesso worker.
- *Per chiuderlo:* canale condiviso (Redis pub/sub) e lock su database o
Redis. L'invariante del round attivo è già un indice e non rientra qui.
*Codice:* `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · `app/auth/rate_limit.py:AuthRateLimiters`
- **[LIM-19]** **Nessuna cronologia per l'utente.** L'utente non vede lo storico
dei round, delle scommesse, dei depositi o dei prelievi: un prelievo fallito
lascia una riga `failed` che l'utente non può mai vedere. Esistono solo
`GET /users/me/last-round-result` (l'ultimo round giocato) e
`GET /bug-reports/mine`. L'operatore ha invece `/admin/rounds`,
`/admin/pending-transactions`, `/admin/audit-log`.
- *Per chiuderlo:* endpoint e sezione di cronologia personale.
*Codice:* `app/api/routes/users.py:last_round_result` · `app/withdrawals/service.py:request_withdrawal`
- **[LIM-20]** **`/guida` è un segnaposto.** Il collegamento funziona ma la
pagina dice che la guida arriverà. (`/report-bug` invece è completa e
tradotta.)
*Codice:* `app/main.py:user_guide` · `app/static/guida.html`
- **[LIM-21]** **La parità delle chiavi di traduzione non è verificata da un
test.** Oggi le 7 lingue hanno le stesse 151 chiavi, ma nulla lo impone: una
chiave mancante ricade sull'inglese e, se manca anche lì, viene mostrata la
chiave grezza.
- *Per chiuderlo:* un test che confronti gli insiemi di chiavi di
`TRANSLATIONS`.
*Codice:* `app/static/i18n.js:t`
- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano
il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un
`detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e
così il 503 del canale SSE oltre il tetto; è coerente con un pannello non
tradotto, ma non è dichiarato altrove.
- *Per chiuderlo:* usare `http_error` anche lì, se il pannello dovrà mai
tradurre o distinguere gli errori.
*Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream`
- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a
differenza degli altri elenchi admin.
- *Impatto:* con molti utenti la risposta cresce senza limite.
*Codice:* `app/api/routes/admin.py:list_users`
+17 -8
View File
@@ -2,6 +2,8 @@
Come gestire la configurazione operativa di PLM Lottery. Presuppone che il
server sia già avviato — vedi [running-the-server.md](running-the-server.md).
Per il funzionamento interno (stati del round, riconciliazione delle
transazioni, connessione Electrum) vedi [come-funziona.md](come-funziona.md).
## Accesso
@@ -23,7 +25,8 @@ sola schermata di login.
- **Utenti** — elenco utenti, saldo, accesso alla chiave privata, reset password
- **Round** — storico round: stato, vincitore, importi, txid di payout
- **Transazioni pendenti** — bet/payout/prelievi non ancora confermati, candidati al fee-bump RBF
- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.)
- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.; catalogo completo in [audit-log.md](audit-log.md))
- **Bug report** — segnalazioni inviate da `/report-bug`, da smistare `open` → `read` → `resolved`
## Parametri
@@ -90,8 +93,11 @@ sempre **chi ha originato la transazione**, non il destinatario: per bet e
prelievi è l'utente stesso (gli torna un resto più piccolo), per i payout è
il pool (il resto che torna all'indirizzo pool si riduce) — la quota del
vincitore e quella delle fee, già fissate, non vengono mai toccate. Se non
c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce e
resta un intervento manuale (vedi "Limiti noti").
c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce: la
transazione resta in attesa finché non conferma da sola o finché il
riconciliatore non la abbandona liberandone le monete (vedi
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
[operativita.md](operativita.md)).
## Round
@@ -125,7 +131,7 @@ Eventi a cui vale la pena prestare attenzione:
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
| `bug_report_status_changed` | Un admin ha cambiato lo stato di una segnalazione (payload: `report_id`, stato precedente e nuovo). Con un token admin unico e condiviso, questa riga è l'unica traccia di chi tocca le segnalazioni. |
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
| `payout_failed` | Un tentativo di payout di un round non è partito; il campo `reason` dice perché. Il round resta in `paying_out` e lo scheduler **riprova da solo al massimo una volta ogni 60 secondi**. Se l'evento si ripete, controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum (procedure in [operativita.md](operativita.md)). |
## Alternative all'interfaccia grafica
@@ -182,11 +188,14 @@ per l'utente: solo un admin col token può farlo.
- Il token è unico e condiviso: non c'è identità per singolo admin né audit
di chi ha cambiato cosa (oltre alla tabella `audit_log` generica).
- Nessun rate limiting sugli endpoint admin (né su registrazione/bet/
prelievo utente).
- Nessun rate limiting sugli endpoint admin, né su bet e prelievo. Solo il
login (tentativi falliti) e la registrazione (5 account/ora per IP) sono
limitati — vedi [sicurezza.md](sicurezza.md).
- Se un payout fallisce (es. Electrum disconnesso, UTXO insufficienti), il
round resta bloccato in `paying_out` senza retry automatico — richiede
intervento manuale.
round resta in `paying_out` e il payout viene ritentato ogni 60 secondi;
alcuni motivi però non si risolvono da soli (vedi
[operativita.md](operativita.md)).
- Elenco completo dei limiti accettati: [decisioni-e-limiti.md](decisioni-e-limiti.md).
Per l'elenco completo dei gap noti vedi la sezione "Known gaps / TODO" in
`CLAUDE.md`.
+2
View File
@@ -2,6 +2,8 @@
Come usare PLM Lottery dall'interfaccia web (`https://<host>/` — vedi
[running-the-server.md](running-the-server.md) per come avviare il server).
Per cosa succede dietro le quinte (depositi, round, estrazione, pagamenti) vedi
[come-funziona.md](come-funziona.md).
## Registrazione e accesso
+1088
View File
File diff suppressed because it is too large. Load diff
+562
View File
@@ -0,0 +1,562 @@
# Operatività
Manuale operativo di PLM Lottery: dove stanno i dati, cosa salvare, cosa
succede a un riavvio, e cosa fare quando qualcosa si blocca. Presuppone
installazione e avvio già fatti: la prima configurazione è in
[setup.md](setup.md), i comandi Docker di tutti i giorni in
[running-the-server.md](running-the-server.md), l'uso del pannello in
[guida-admin.md](guida-admin.md). Qui c'è quello che quelle guide non coprono.
Le procedure citano spesso eventi del registro di audit: il loro significato
esatto è in [audit-log.md](audit-log.md). Il funzionamento interno che spiega
*perché* un round si blocca è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md),
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
[connessione-electrum.md](connessione-electrum.md).
## Topologia
```mermaid
flowchart LR
Internet -- "80 / 443" --> caddy["container caddy<br/>(TLS, header di sicurezza)"]
caddy -- "app:8123" --> app["container app<br/>(FastAPI + 6 attività)"]
app -- "Electrum SSL" --> E1["server Electrum<br/>primario"]
app -. "rotazione e quorum" .-> E2["server di riserva<br/>(ELECTRUM_FALLBACK_SERVERS)"]
app --- db[("./data/db<br/>plm_lottery.db")]
app --- keys[("./data/keys<br/>master.xprv.enc")]
app --- logs[("./data/logs<br/>app.log")]
caddy --- cdata[("volumi Docker<br/>caddy_data, caddy_config")]
```
- **[OPS-01]** Il deployment è composto da due container definiti in
`docker-compose.yml`, entrambi con `restart: unless-stopped`: `app` (questo
codice, porta 8123 esposta solo sulla rete interna) e `caddy` (reverse proxy
sulle porte 80 e 443 dell'host). Il container `app` monta tre cartelle
dell'host: `./data/db` su `/app/db_data`, `./data/keys` su `/app/key_data`,
`./data/logs` su `/app/logs`; `DATABASE_URL` e `MASTER_KEY_PATH` sono
sovrascritti per puntare lì. Caddy tiene certificati e stato ACME nei volumi
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
*Codice:* `docker-compose.yml`, `Caddyfile`
- **[OPS-02]** All'avvio il container `app` esegue, in quest'ordine: controllo
che il file della master key esista (altrimenti esce con errore e un
suggerimento del comando da lanciare), `alembic upgrade head`, avvio del
server. Durante l'avvio dell'applicazione `validate_runtime_secrets` rifiuta
di partire se `JWT_SECRET` è più corto di 32 caratteri o
`XPRV_ENCRYPTION_KEY` è vuoto, e `parse_endpoints` rifiuta una voce malformata
in `ELECTRUM_FALLBACK_SERVERS`. Un `ADMIN_TOKEN` vuoto non blocca l'avvio: il
pannello resta chiuso a tutti.
*Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`,
`app/config.py:validate_runtime_secrets`,
`app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
scritture più recenti possono trovarsi solo nel file `-wal`. I tre file vanno
sempre trattati insieme.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
- **[OPS-04]** `/health` risponde sempre `{"status": "ok"}` finché il processo
è vivo: **non** dice nulla sulla connessione Electrum né sullo stato dei
round. Non va usato come unico segnale di salute (vedi
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
*Codice:* `app/main.py:health`
## Backup e ripristino
### Cosa salvare
| Cosa | Dove | Quanto spesso | Perché |
|---|---|---|---|
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
| Master key cifrata | `./data/keys/master.xprv.enc` | Una volta (non cambia) | Da essa derivano tutti gli indirizzi e il montepremi |
| `XPRV_ENCRYPTION_KEY` | `.env` | Una volta, **in un luogo separato** dalla master key | Senza, la master key cifrata è illeggibile |
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum |
| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano |
- **[OPS-05]** Master key cifrata e `XPRV_ENCRYPTION_KEY` si salvano **in posti
diversi**: uno dei due da solo non basta a spendere, entrambi insieme danno il
controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i
fondi di tutti gli utenti e del montepremi. In alternativa si può conservare
offline la xprv in chiaro, ottenuta con `scripts/decrypt_master_key.py`: in
quel caso quella copia da sola è la chiave di tutto.
*Codice:* `app/wallet/keystore.py:decrypt_xprv`,
`scripts/decrypt_master_key.py`
- **[OPS-06]** Un backup coerente del database, **senza fermare il server**, si
fa con l'API di backup di SQLite, che include il contenuto del file WAL:
```bash
docker compose exec app python -c "import sqlite3; s=sqlite3.connect('/app/db_data/plm_lottery.db'); d=sqlite3.connect('/app/db_data/backup.db'); s.backup(d); d.close(); s.close()"
# il file compare come ./data/db/backup.db sull'host: spostarlo altrove
```
Copiare a mano il solo `plm_lottery.db` mentre il server gira **non** è un
backup affidabile ([OPS-03]). In alternativa: `docker compose stop app`,
copiare i tre file, `docker compose start app`, ma quel riavvio ha le
conseguenze descritte in [OPS-10].
*Codice:* `app/db/base.py:_register_sqlite_pragmas`
### Ripristino
- **[OPS-07]** Procedura di ripristino:
1. `docker compose down`.
2. Rimettere `.env` (con la stessa `XPRV_ENCRYPTION_KEY` usata per cifrare la
master key) e `./data/keys/master.xprv.enc`. Se si ha solo la xprv in
chiaro, ricifrarla con `scripts/encrypt_master_key.py`.
3. Rimettere il database in `./data/db/plm_lottery.db` e **cancellare**
eventuali `-wal`/`-shm` rimasti dall'istanza precedente, che non
appartengono al file ripristinato.
4. `docker compose up -d --build`. Le migrazioni si applicano da sole.
5. Controllare in `/admin` lo stato del round attivo e le transazioni
pendenti; un round rimasto in `drawing` nel backup va sbloccato con
[OPS-13].
All'avvio il riconciliatore delle transazioni e, dopo 300s, il controllo
periodico dei depositi riallineano il database alla catena: UTXO spesi dopo
il backup vengono segnati come spesi all'esterno (`utxo_spent_externally`),
quelli arrivati dopo vengono accreditati.
*Codice:* `app/tx/reconcile.py:reconcile_once`,
`app/deposits/reconcile.py:DepositReconciler`
- **[OPS-08]** **Rischio di un backup vecchio: indirizzi riassegnati.** Il
nuovo utente riceve l'indice di derivazione `max(derivation_index) + 1`.
Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro
indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo
**stesso indirizzo** di un utente perso, e con esso i fondi che quello vi
aveva depositato. Il codice non ha alcuna protezione contro questo caso, né
un modo per sospendere le registrazioni. Dopo un ripristino da un backup non
recentissimo, prima di riaprire il servizio, bisogna ricostruire gli utenti
mancanti (ad esempio dai log di `POST /auth/register` in `app.log`, se ancora
presenti) oppure accettare consapevolmente il rischio.
*Codice:* `app/auth/routes.py:register`
## Riavvio
Un riavvio avviene a ogni deploy (`docker compose up -d --build`), a ogni
modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
- **[OPS-09]** Al riavvio nessuna attività lavora finché il listener non ha una
connessione con un'altezza di catena nota. Poi, a seconda dello stato del
round attivo:
| Stato al momento del riavvio | Cosa succede |
|---|---|
| nessun round / `closed` | Si apre un nuovo round rispettando il cooldown del round precedente |
| `open` | Riprende: la scadenza è calcolata dai valori salvati sul round |
| `closing` | Riprende: si aspetta che le scommesse in volo confermino, poi si estrae |
| `drawing` | **Non riprende.** Il round resta in `drawing` per sempre e nessun nuovo round si apre. Serve [OPS-13] |
| `paying_out` | Riprende: il pagamento viene ritentato subito se non c'è un `payout_failed` negli ultimi 60s; un pagamento già in volo non viene duplicato |
Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal
riconciliatore, non prima di 120 secondi dalla loro creazione e al primo
passaggio con la connessione attiva (i passaggi sono ogni 120s, e quello
"immediato" all'avvio di norma trova la connessione non ancora pronta e viene
saltato): promosse se sono in catena, abbandonate altrimenti. Si perdono invece, perché solo in memoria: i
blocchi dei login per troppi tentativi e la quota di registrazioni per IP, le
connessioni SSE (i browser si riconnettono da soli), i lock per utente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
- **[OPS-10]** **Riavvio sicuro** (deploy, modifica di `.env`, rotazione di un
segreto):
1. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
(`POST /admin/pause`). Il round in corso finisce normalmente.
2. Aspettare che `GET /rounds/current` non riporti più un round attivo
(`round_id` vuoto) e che in "Transazioni pendenti" non ci siano scommesse
o prelievi `building`.
3. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
per un cambio di `.env`).
4. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
poi "Riprendi lotteria" (`POST /admin/resume`).
La pausa non blocca scommesse e prelievi sul round in corso, né
registrazioni e depositi. Un riavvio con il round in `open`, `closing` o
`paying_out` è comunque sicuro ([OPS-09]); quello da evitare è il riavvio
durante `drawing`.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_admin.py`
## Procedure di intervento
Ogni procedura ha la stessa forma: come si riconosce, perché succede, cosa
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host (i messaggi
dell'applicazione non compaiono in `docker compose logs`, vedi
[audit-log.md](audit-log.md)).
### Electrum irraggiungibile
- **[OPS-11]**
**Sintomi.** In `/admin` la pillola "Blocco #N" e `chain_tip_height` di
`GET /rounds/current` restano fermi sull'ultimo valore noto (l'altezza non si
azzera alla disconnessione, quindi *non* si vede un errore, si vede un numero
che non cresce). Scommesse e prelievi rispondono 503 `network_unavailable`.
Nel log: `Electrum session on … failed`, `Electrum session on … ending`,
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
altre attività saltano i loro cicli; un round in `drawing` continua ad
aspettare e dopo 360s scrive `draw_stalled`.
**Causa.** Tutti i server configurati sono giù, irraggiungibili, o rifiutati
(un header che non supera la validazione chiude la sessione e fa passare al
server successivo).
**Cosa fare.**
1. Nessuna azione è necessaria per il ripristino: il listener prova il
server successivo subito e, dopo un giro completo di fallimenti, riprova
con attesa crescente da 1 a 30 secondi, all'infinito.
2. Verificare la raggiungibilità dal server:
`docker compose exec app python scripts/electrum_smoke_test.py` (prova
solo il primario, vedi [OPS-26]).
3. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
riavviare. **Se un round è in `drawing`, il riavvio lo blocca**: seguire
poi [OPS-13].
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[OPS-12]** Con `ELECTRUM_FALLBACK_SERVERS` vuoto la corroborazione passa
sempre (non c'è nessuno a cui chiedere), ma un solo server guasto ferma
tutto. Con server di riserva configurati ma tutti irraggiungibili, la
corroborazione **fallisce**: depositi non accreditati, estrazione che scarta
ogni blocco (`draw_header_corroboration_failed`) e segnalazioni di spesa
esterna ignorate, anche se il server attivo funziona. In produzione servono
almeno due server di riserva raggiungibili.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
### Round bloccato in `drawing`
- **[OPS-13]**
**Sintomi.** `GET /rounds/current` riporta `status: "drawing"` con
`draw_waiting_since` lontano nel tempo; nessun nuovo round si apre. Possibili
cause visibili:
- righe `draw_stalled` ripetute ogni 6 minuti: l'altezza della catena non
sale (Electrum, vedi [OPS-11]);
- righe `draw_header_corroboration_failed`, una per blocco: i blocchi
arrivano ma gli altri server non li confermano (vedi [OPS-12]);
- **nessuna riga nuova e nessun messaggio `still waiting for a block` nel
log**: l'attesa non è più in corso. Succede dopo un riavvio del processo,
oppure dopo un'eccezione durante l'attesa (messaggio
`round scheduler tick failed`, per esempio un database bloccato mentre si
scriveva `draw_stalled`). In questo caso il round non si sbloccherà mai da
solo.
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
server di riserva): l'estrazione avviene da sola al primo blocco valido.
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
intervento possibile è una modifica manuale del database che riporti il round
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo:
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
2. `docker compose stop app`.
3. Aggiornare lo stato:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); n=c.execute(\"UPDATE rounds SET status='closing' WHERE id=<ID> AND status='drawing' AND winner_user_id IS NULL\").rowcount; c.commit(); print(n, 'riga aggiornata')"
```
Deve stampare `1 riga aggiornata`.
4. `docker compose start app`.
5. Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il
round in `drawing` con un nuovo `drawing_started_at` e aspetta un blocco
successivo all'altezza *attuale*; poi estrae e paga normalmente
(`winner_drawn`, `payout_sent`).
Conseguenze da sapere: l'estrazione usa un blocco successivo al momento
dello sblocco invece del primo blocco dopo la chiusura, quindi quale blocco
decide il vincitore dipende da quando l'operatore interviene; la modifica non
lascia alcuna riga nel registro di audit, quindi va annotata altrove, con
data, id del round e motivo. I partecipanti non cambiano: il round è fuori da
`open`, quindi nessuna scommessa nuova può entrare.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`,
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`
### Round bloccato in `closing`
- **[OPS-14]**
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
`has_pending_bets` vero; nel log compare
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
riga `bet` di quel round in `building` o `pending`.
**Causa.** Il round non estrae finché ogni scommessa trasmessa non ha una
conferma. Una scommessa `pending` che resta nel mempool non viene mai
abbandonata dal riconciliatore, che abbandona solo ciò che **non compare**
nella cronologia dell'indirizzo: finché il server Electrum la conosce, anche
non confermata, il round aspetta.
**Cosa fare.**
1. Controllare `attempt_count` e `fee_rate_sat_vb` della riga: il
`RbfBumper` alza la commissione di 1 sat/vB ogni `rbf_timeout_seconds`
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà da
sola.
2. Se nel log compare `could not bump pending_transaction N`, l'RBF è
impossibile: vedi [OPS-17].
3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve
al suo prossimo passaggio (ogni 120s).
4. Una transazione che il server non conosce più viene abbandonata dopo 6
ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
rimosso e il round può chiudere.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
### Round bloccato in `paying_out`
- **[OPS-15]**
**Sintomi.** `status: "paying_out"` a lungo e, nel registro di audit, righe
`payout_failed` dello stesso round, al massimo una al minuto. Il campo
`reason` dice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una
volta rimossa la causa, il pagamento parte al tentativo successivo senza
altri interventi.
| `reason` | Cosa fare |
|---|---|
| `electrum client not connected` | Vedi [OPS-11]. Si risolve da sola al ritorno della connessione |
| `no fee_address configured` | Impostare `fee_address` in `/admin` → Parametri. Succede solo se è stato svuotato a round in corso |
| `winner user not found` | Possibile solo se la riga dell'utente è stata cancellata a mano dal database. Nessuna procedura supportata: ripristinare la riga |
| `insufficient pool UTXOs` | Vedi sotto |
| `too_many_inputs` | Il pagamento richiederebbe più di 500 UTXO del montepremi (resti accumulati in molti round). Serve consolidare gli UTXO del montepremi con la sua chiave: **nessuno strumento lo fa**, vedi [OPS-24] |
| `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata |
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto circa 1.000 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
| `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) |
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore entro 2–4 minuti, `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb` |
**`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati**
dell'indirizzo del montepremi, escludendo quelli già riservati da un altro
pagamento `building` o `pending` non risolto. Controllare in "Transazioni
pendenti" se c'è un `payout` di un round precedente ancora aperto: finché
resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è
mostrato dal pannello; si ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`
e se ne può controllare il saldo con un explorer.
Un pagamento già in volo (riga `payout` `building` o `pending` del round)
non viene mai duplicato: finché esiste, i tentativi vengono saltati senza
scrivere `payout_failed` e il round aspetta la conferma o l'abbandono.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py`
### Transazione abbandonata
- **[OPS-16]**
**Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log
`abandoned <tipo> pending_transaction …`; la riga in "Transazioni pendenti"
passa a `failed`.
**Causa.** La transazione non compare più nella cronologia del proprio
indirizzo: mai partita (riga `building` da oltre 120s) o scomparsa dal
mempool (riga `pending` da oltre 6 ore dal primo invio).
**Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e
saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può
riscommettere se il round è ancora aperto); per un prelievo la richiesta è
`failed` e l'utente, che non vede lo storico, va avvisato di ripeterla; per
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo. Se
gli abbandoni sono frequenti, la commissione di rete configurata è
probabilmente troppo bassa.
*Codice:* `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_reconcile.py`
### RBF impossibile
- **[OPS-17]**
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
`could not bump pending_transaction N` con un `RbfError`: "no change output
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
Nessuna riga di audit. `attempt_count` smette di salire.
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
non si può fare.
**Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa
alla commissione che ha: conferma da sola quando il mempool si svuota,
oppure, se sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se
blocca un round in `closing` ([OPS-14]), l'alternativa manuale è spendere il
resto non confermato con una commissione alta (CPFP) usando la chiave
dell'utente ([OPS-23]); il sistema accrediterà l'uscita della nuova
transazione come un normale deposito.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
*Test:* `tests/unit/test_broadcast.py`
### Deposito non accreditato
- **[OPS-18]**
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
**Cause, in ordine di probabilità.**
1. La transazione non ha ancora 1 conferma: il deposito non viene mostrato
nemmeno come "in attesa" finché non è in un blocco.
2. La corroborazione fallisce: nel log
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
spesso `could not corroborate … with any of N other configured server(s)`.
Vedi [OPS-12].
3. La sottoscrizione dell'indirizzo si è persa (log
`could not subscribe a newly-registered user's address` o
`resubscribing all users failed partway through`).
4. Electrum irraggiungibile ([OPS-11]).
**Cosa fare.** Verificare con un explorer che la transazione sia confermata
e paghi esattamente l'indirizzo dell'utente (sezione Utenti del pannello).
Poi aspettare: `DepositReconciler` ricontrolla ogni indirizzo ogni 300
secondi e accredita tutto ciò che il quorum conferma, indipendentemente
dalle notifiche. Un riavvio risottoscrive tutti gli indirizzi, ma non serve
e va fatto con [OPS-10]. Ogni accredito lascia una riga `deposit_credited`.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`,
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_electrum_listener.py`,
`tests/unit/test_deposit_reconcile.py`
### Utente bloccato fuori
- **[OPS-19]**
**Password dimenticata.** In `/admin` → Utenti → "Reset": viene generata una
nuova password casuale, mostrata una sola volta, da comunicare all'utente per
un canale di cui ci si fida (non esiste un'email). Tutte le sessioni esistenti
dell'utente vengono invalidate. Resta la riga `admin_password_reset`.
Verificare l'identità di chi chiede: chi riceve la password controlla i fondi.
**Troppi tentativi.** Dopo 5 login falliti sullo stesso username (20 dallo
stesso IP) ogni ulteriore errore impone un'attesa che parte da 2 secondi e
raddoppia fino a 300; il contatore si azzera dopo 15 minuti senza errori o al
primo login riuscito. Basta aspettare; un riavvio azzera tutti i contatori
ma non è necessario.
*Codice:* `app/api/routes/admin.py:reset_user_password`,
`app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_rate_limit.py`
### Segreto compromesso
- **[OPS-20]** **`ADMIN_TOKEN` trapelato.** Chi lo possiede può esportare la
chiave privata di qualunque utente, cambiare `fee_address`, resettare
password e mettere in pausa la lotteria.
1. Generare un nuovo token
(`python -c "import secrets; print(secrets.token_urlsafe(32))"`),
scriverlo in `.env` e riavviare ([OPS-10], ma senza aspettare la fine del
round se l'urgenza lo richiede ed evitando solo lo stato `drawing`). Il
vecchio token smette di funzionare subito; gli operatori rientrano nel
pannello con quello nuovo.
2. Nel registro di audit cercare, dal momento presunto della fuga:
`admin_privkey_accessed` (quali chiavi sono uscite), `config_updated`
(soprattutto `fee_address`), `admin_password_reset` (account presi), e per
quegli utenti `withdrawal_sent`.
3. **La chiave di un utente esportata resta compromessa per sempre**:
l'indirizzo di un utente è permanente e il codice non ha un modo per
assegnargliene un altro. L'unica difesa è spostare subito i fondi di
quell'indirizzo ([OPS-23]) e avvisare l'utente di non depositarvi più.
*Codice:* `app/api/routes/admin.py:require_admin`
- **[OPS-21]** **`JWT_SECRET` trapelato.** Chi lo possiede può fabbricare una
sessione valida per qualunque utente e quindi scommettere o prelevarne i
fondi. Generare un nuovo segreto di almeno 32 caratteri, scriverlo in `.env` e
riavviare: **tutte** le sessioni esistenti diventano invalide e ogni utente
deve rifare il login. Poi controllare `withdrawal_sent` nel registro di audit
per prelievi inattesi. Per invalidare le sessioni di un solo utente basta
invece un reset della sua password ([OPS-19]).
*Codice:* `app/auth/security.py:decode_access_token`,
`app/config.py:validate_runtime_secrets`
- **[OPS-22]** **Rotazione di `XPRV_ENCRYPTION_KEY`** (la master key non
cambia, cambia solo la sua cifratura):
1. `PYTHONPATH=. python scripts/decrypt_master_key.py` con la chiave vecchia
in `.env`, e conservare la xprv stampata solo per il tempo necessario.
2. Generare la chiave nuova
(`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`)
e scriverla in `.env`.
3. `PYTHONPATH=. python scripts/encrypt_master_key.py --overwrite` e
incollare la xprv.
4. Riavviare ([OPS-10]) e aggiornare i backup ([OPS-05]).
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
xprv in chiaro), sono a rischio tutti i fondi degli utenti e del montepremi.
Non esiste una procedura supportata per passare a una nuova master key: ogni
indirizzo, compreso quello del montepremi, deriva da essa, e cambiarla
cambierebbe tutti gli indirizzi senza spostare i fondi né aggiornare il
database.
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`
### Intervento manuale sui fondi
- **[OPS-23]** **Spostare i fondi di un utente con la sua chiave privata.**
Serve per restituire fondi bloccati, per un CPFP ([OPS-17]) o dopo una fuga
della chiave ([OPS-20]).
1. In "Transazioni pendenti" verificare che l'utente non abbia scommesse o
prelievi `building`/`pending`: i loro input sono già impegnati e
spenderli altrove crea un conflitto che fa fallire una delle due
transazioni.
2. Per evitare che l'utente agisca nel frattempo, si può resettarne la
password ([OPS-19]), che chiude le sue sessioni.
3. `/admin` → Utenti → "Mostra" (riga `admin_privkey_accessed`). La chiave
WIF va importata in un portafoglio PLM (per esempio PalladiumWallet), mai
salvata altrove.
4. Inviare i fondi dove serve.
5. Quando la spesa è confermata, il listener vede gli UTXO scomparire, chiede
conferma agli altri server e li segna come spesi all'esterno
(`utxo_spent_externally`): il saldo nel database scende da solo. Nulla va
modificato a mano.
**Restituire fondi a un utente** non richiede chiavi: si inviano al suo
indirizzo e vengono accreditati come un deposito (`deposit_credited`).
*Codice:* `app/api/routes/admin.py:user_privkey`,
`app/deposits/service.py:mark_utxos_spent_externally`
- **[OPS-24]** **Fondi del montepremi.** La chiave del montepremi
(`m/84'/746'/0'/1/0` della master key) non è esportabile dal pannello né da
alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta con
`scripts/decrypt_master_key.py`. Non c'è quindi una procedura supportata per
consolidare gli UTXO del montepremi (`too_many_inputs`) o per recuperare
resti accumulati. Spendere UTXO del montepremi fuori dal sistema non è
rilevato come spesa esterna (l'indirizzo non è in `utxo_events`) e può far
fallire il pagamento di un round in corso (`insufficient pool UTXOs` o una
trasmissione rifiutata): farlo solo senza round in `paying_out`.
*Codice:* `app/wallet/hd.py:derive_pool_key`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`
## Segnali da tenere d'occhio
- **[OPS-25]** Non esiste un sistema di allarmi: i segnali vanno controllati
a mano o con uno strumento esterno che interroghi l'API e legga il log.
| Segnale | Dove | Allarme se |
|---|---|---|
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di ~10 minuti (blocchi ogni 120s): Electrum giù ([OPS-11]) |
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di qualche blocco; `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
| Istanza configurata | `lottery_configured` in `GET /rounds/current` | `false`: manca `fee_address`, nessun round si apre |
| Pausa dimenticata | `lottery_paused` in `GET /rounds/current` | `true` fuori da una manutenzione |
| Pagamenti ed estrazioni | Audit: `payout_failed`, `draw_stalled`, `draw_header_corroboration_failed` | Qualunque occorrenza |
| Transazioni perse | Audit: `pending_tx_abandoned` | Frequenti: commissione troppo bassa |
| Spese esterne | Audit: `utxo_spent_externally` | Non dovute a un intervento dell'operatore: chiave dell'utente compromessa o server che mente |
| Uso del pannello | Audit: `admin_privkey_accessed`, `config_updated`, `admin_password_reset` | Non attribuibili a un operatore: `ADMIN_TOKEN` compromesso ([OPS-20]) |
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` vecchio di ore |
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
## Script manuali
Si lanciano dal venv sull'host (`source .venv/bin/activate`, con
`PYTHONPATH=.`) oppure nel container con `docker compose run --rm app python
scripts/<nome>.py`. Nel container `MASTER_KEY_PATH` punta a
`/app/key_data/master.xprv.enc`, cioè allo stesso file `./data/keys` dell'host;
dal venv conta il `MASTER_KEY_PATH` di `.env`.
- **[OPS-26]** Gli script disponibili:
| Script | Cosa fa | Quando |
|---|---|---|
| `scripts/generate_master_key.py` | Crea una master key casuale, la cifra con `XPRV_ENCRYPTION_KEY` e la scrive in `MASTER_KEY_PATH`. Si rifiuta se il file esiste già | Una sola volta, alla prima installazione ([setup.md](setup.md)) |
| `scripts/encrypt_master_key.py` | Cifra una xprv incollata (input nascosto) e la scrive in `MASTER_KEY_PATH`, con ripiego su `./data/keys/` se la cartella di `MASTER_KEY_PATH` non esiste. Rifiuta una xpub e un file già presente senza `--overwrite` | Installazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) |
| `scripts/decrypt_master_key.py` | Decifra e stampa la xprv, dopo aver chiesto di scrivere `yes`. Cerca il file in `MASTER_KEY_PATH`, poi in `./data/keys/master.xprv.enc` | Backup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) |
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un server di riserva, lanciarlo con `ELECTRUM_HOST`/`ELECTRUM_PORT` impostati su quel server |
`generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`.
*Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`,
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`,
`scripts/electrum_smoke_test.py`
+84
View File
@@ -0,0 +1,84 @@
# Processo di modifica: dalla documentazione al codice
La cartella `docs/` è la **specifica** di PLM Lottery. Descrive cosa il
software fa e perché; il codice è l'implementazione di questa specifica.
Ogni modifica di comportamento parte da qui.
## Convenzione dei requisiti
Ogni regola verificabile ha un **identificativo stabile**, nella forma
`PREFISSO-NN`, scritto in grassetto all'inizio del punto che la descrive,
seguito dai riferimenti all'implementazione e, dove esistono, ai test:
```markdown
- **[RND-11]** Alla scadenza il round passa subito a `closing`: da quel momento
nessuna scommessa è più accettata.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
```
Regole della convenzione:
- **Un identificativo non si riusa mai.** Un requisito eliminato resta nel
documento come `~~[RND-11]~~ Rimosso: <motivo>`, così i riferimenti già
scritti (commenti, commit, test) non puntano a una regola diversa.
- **I numeri non si rinumerano.** Un requisito nuovo prende il numero
successivo al più alto del suo prefisso, anche se finisce in mezzo al testo.
- **I riferimenti al codice** sono `percorso:funzione` o `percorso:Classe.metodo`,
mai numeri di riga (cambiano a ogni modifica).
- Il testo descrittivo che non contiene regole (introduzioni, spiegazioni,
esempi) non ha identificativo.
### Prefissi
| Prefisso | Documento | Ambito |
|---|---|---|
| `ARC` | [architettura.md](architettura.md) | Struttura del codice, processi, frontend, test |
| `DAT` | [modello-dati.md](modello-dati.md) | Tabelle, campi, stati, vincoli |
| `API` | [api.md](api.md) | Endpoint, formato delle risposte, codici di errore |
| `SEC` | [sicurezza.md](sicurezza.md) | Chiavi, autenticazione, limiti, rischi accettati |
| `REG` | [registrazione-e-depositi.md](registrazione-e-depositi.md) | Registrazione e account |
| `DEP` | [registrazione-e-depositi.md](registrazione-e-depositi.md) | Depositi e saldo |
| `BET` | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Scommesse |
| `WDR` | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Prelievi |
| `RND` | [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | Round, estrazione, pagamento |
| `TX` | [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | Ciclo di vita delle transazioni, RBF, riconciliazione |
| `ELC` | [connessione-electrum.md](connessione-electrum.md) | Connessione alla rete |
| `AUD` | [audit-log.md](audit-log.md) | Eventi del registro di audit |
| `OPS` | [operativita.md](operativita.md) | Procedure operative |
| `DEC` | [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto |
| `LIM` | [decisioni-e-limiti.md](decisioni-e-limiti.md) | Limiti noti accettati |
## Il ciclo di una modifica
1. **Modificare la specifica.** Si cambia il testo del requisito in `docs/`
(o se ne aggiunge uno nuovo con il prossimo numero libero). Se la modifica
tocca una decisione (`DEC-*`) o un limite (`LIM-*`), si aggiorna anche
quello.
2. **Individuare l'impatto.** I riferimenti *Codice* e *Test* del requisito
dicono dove intervenire. Si cercano anche le altre occorrenze
dell'identificativo nel repository (`git grep RND-11`) e i requisiti di
altri documenti che dipendono dallo stesso codice.
3. **Implementare e testare.** Si modifica il codice e si aggiunge o aggiorna
un test che verifica il nuovo comportamento. Il test cita l'identificativo
nella sua docstring.
4. **Allineare i riferimenti.** Se il codice si è spostato (funzione
rinominata, file diviso), si aggiornano i riferimenti *Codice* del
requisito.
5. **Commit unico.** Specifica, codice e test entrano nello stesso commit,
con l'identificativo nel messaggio (ad esempio
`Close the round on its own deadline (RND-11)`).
Una correzione di bug che non cambia la specifica (il codice non faceva ciò
che il documento già diceva) segue la convenzione `B-nn` descritta in
[storico-bug.md](storico-bug.md): numero successivo, commit dedicato, test di
regressione. Se invece il bug rivela che la specifica era sbagliata o
incompleta, si corregge prima la specifica, come per qualunque altra modifica.
## Documenti della specifica
Il punto d'ingresso è [come-funziona.md](come-funziona.md). Le guide
operative ([setup.md](setup.md), [running-the-server.md](running-the-server.md),
[guida-utente.md](guida-utente.md), [guida-admin.md](guida-admin.md)) non
contengono requisiti: descrivono come usare il software, e si aggiornano
quando la specifica cambia qualcosa di visibile.
+287
View File
@@ -0,0 +1,287 @@
# Registrazione, depositi e saldo
Fasi 1 e 2 del [funzionamento generale](come-funziona.md): come nasce un
account e come i fondi entrano nella piattaforma.
I requisiti usano i prefissi `REG` (registrazione e account) e `DEP` (depositi
e saldo), secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Gli endpoint citati sono
descritti in dettaglio in [api.md](api.md); hashing, token e limiti di
frequenza in [sicurezza.md](sicurezza.md); le colonne delle tabelle in
[modello-dati.md](modello-dati.md); gli eventi di audit in
[audit-log.md](audit-log.md).
## Registrazione
L'utente sceglie username e password (`POST /auth/register`). Il server:
1. salva la password come hash **Argon2** (mai in chiaro);
2. assegna all'utente il prossimo indice libero e deriva da questo il suo
indirizzo, secondo lo schema BIP84 `m/84'/746'/0'/0/<indice>` a partire
dalla master xprv cifrata. Ne risulta un indirizzo P2WPKH (`plm1…`);
3. collega l'indirizzo al profilo, in modo **permanente**;
4. si iscrive, via Electrum, alle notifiche di quell'indirizzo. Questo passo
è "al meglio": se fallisce, l'indirizzo viene comunque ripreso alla
riconnessione successiva o al giro periodico di `DepositReconciler`
(ogni 5 minuti).
- **[REG-01]** Lo username è lungo da 3 a 32 caratteri, composto solo da
lettere ASCII, cifre, `_`, `.` e `-`; la password è lunga da 8
(`MIN_PASSWORD_LENGTH`) a 256 caratteri. Una richiesta che non rispetta
questi vincoli è rifiutata con HTTP 422 prima di qualunque altra verifica.
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/security.py:MIN_PASSWORD_LENGTH` ·
*Test:* `tests/unit/test_users.py`
- **[REG-02]** Gli username **non distinguono maiuscole e minuscole**:
`Mario` e `mario` sono lo stesso account. Uno username già usato, in
qualunque combinazione di maiuscole, è rifiutato con `username_taken`
(HTTP 409), sia dal controllo iniziale sia se la collisione emerge solo al
salvataggio (registrazione concorrente). L'unicità è garantita dal database
con un indice univoco su `lower(username)`. Il nome resta però salvato come
è stato digitato.
*Codice:* `app/auth/routes.py:register`, `app/db/models.py:User` ·
*Test:* `tests/unit/test_auth.py`
- **[REG-03]** La password è salvata solo come hash Argon2, calcolato fuori
dal ciclo di eventi (in un thread separato) per non bloccare le attività in
background.
*Codice:* `app/auth/security.py:hash_password_async` ·
*Test:* `tests/unit/test_security.py`
- **[REG-04]** L'indice di derivazione del nuovo utente è il massimo indice
esistente più uno (0 per il primo utente); l'indirizzo è il P2WPKH di
`m/84'/746'/0'/0/<indice>`, con prefisso bech32 `plm`. Se il salvataggio
fallisce per una collisione sull'indice (due registrazioni concorrenti), si
riprova con un indice ricalcolato fino a 5 volte (`_MAX_REGISTER_RETRIES`),
poi la richiesta fallisce con `derivation_index_conflict` (HTTP 409).
*Codice:* `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` ·
*Test:* `tests/unit/test_hd.py`
- **[REG-05]** L'indirizzo è **permanente** e unico per utente, ed è allo
stesso tempo indirizzo di deposito, destinazione delle vincite e
destinazione del resto di ogni scommessa e prelievo: non esiste un
"indirizzo vincite" separato.
*Codice:* `app/db/models.py:User`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
- **[REG-06]** Una registrazione riuscita risponde HTTP 201 con un token di
sessione (`access_token`) e l'indirizzo: l'utente è già autenticato, senza
un login separato.
*Codice:* `app/auth/routes.py:register` ·
*Test:* `tests/unit/test_users.py`
- **[REG-07]** Subito dopo il salvataggio, l'indirizzo viene aggiunto agli
indirizzi osservati e, se la connessione Electrum è attiva, gli ci si iscrive
in un'attività separata. Se non c'è connessione, o l'iscrizione fallisce,
l'errore viene solo registrato nei log: la registrazione riesce comunque, e
l'indirizzo viene ripreso dalla reiscrizione completa alla prossima
riconnessione ([ELC-18](connessione-electrum.md#riconnessione)) o dal giro
periodico di [DEP-10](#dep-10).
*Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[REG-08]** La registrazione è limitata a **5 account all'ora per
indirizzo IP**. Il limite è verificato prima di calcolare l'hash, e conta
solo le registrazioni riuscite; oltre il limite la risposta è
`rate_limited` (HTTP 429) con `retry_after_seconds`. I dettagli sono in
[sicurezza.md](sicurezza.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_auth.py`
### Sessione e password
Il login (`POST /auth/login`) restituisce un token JWT valido 24 ore. Cambiare
la password (dal Profilo) o farsela reimpostare dall'operatore invalida
**tutte** le sessioni aperte in precedenza.
- **[REG-09]** Il login confronta lo username senza distinguere maiuscole e
minuscole. Username inesistente e password sbagliata producono la stessa
risposta, `invalid_credentials` (HTTP 401), così che non si possa scoprire
quali account esistono. Una risposta riuscita contiene `access_token` e
l'indirizzo dell'utente.
*Codice:* `app/auth/routes.py:login` ·
*Test:* `tests/unit/test_auth.py`
- **[REG-10]** I tentativi di login falliti sono frenati sia per username sia
per IP; durante il blocco la risposta è `rate_limited` (HTTP 429) con
`retry_after_seconds`. Un login riuscito azzera il contatore dello username,
non quello dell'IP. Soglie e tempi sono in [sicurezza.md](sicurezza.md).
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter` ·
*Test:* `tests/unit/test_auth.py`
- **[REG-11]** Il token di sessione scade dopo 24 ore (`jwt_expire_minutes`)
e porta con sé la versione del token dell'utente (`token_version`). Un token
la cui versione non coincide più con quella dell'utente, oppure scaduto,
malformato o di un utente inesistente, è rifiutato con `session_expired`
(HTTP 401) dagli endpoint autenticati, e trattato come "non autenticato"
dagli endpoint accessibili anche senza login. Un token emesso prima che la
versione esistesse vale come versione 0.
*Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token`, `app/auth/dependencies.py:get_current_user`, `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py`
- **[REG-12]** Il cambio password (`POST /users/me/change-password`, dal
Profilo) richiede la password attuale — altrimenti `current_password_incorrect`
(HTTP 401) — e una nuova password di almeno 8 caratteri — altrimenti
`password_too_short` (HTTP 400). Se riesce, incrementa `token_version`,
invalidando **tutte** le sessioni aperte, compresa quella della richiesta, e
restituisce un token nuovo perché la sessione corrente continui senza nuovo
login.
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
L'unica strada è il reset dall'operatore
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
nuova, la restituisce **una sola volta** perché venga comunicata
all'utente, incrementa `token_version` (invalidando tutte le sessioni) e
scrive l'evento `admin_password_reset`. Un utente inesistente dà HTTP 404.
Procedura in [guida-admin.md](guida-admin.md).
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_admin.py`
### Il conto del montepremi
Oltre agli indirizzi utente, dalla stessa master key si deriva un indirizzo
riservato al **montepremi**. Tutte le scommesse vengono inviate lì, e i
pagamenti ai vincitori sono firmati con la sua chiave.
- **[REG-14]** L'indirizzo del montepremi è il P2WPKH del ramo `1/0`
dell'account (`m/84'/746'/0'/1/0`), mentre gli utenti stanno sul ramo `0`.
Le sue monete non sono registrate tra le monete degli utenti: il pagamento
le legge direttamente dalla rete (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)).
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` ·
*Test:* `tests/unit/test_pending_address.py`
## Deposito
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno. Non c'è
un importo minimo.
```mermaid
flowchart TB
A["Arriva una notifica Electrum<br/>per l'indirizzo, oppure<br/>il giro periodico di controllo"] --> B["Si legge l'elenco delle<br/>monete (UTXO) dell'indirizzo<br/>dal server attivo"]
B --> C{"Moneta nuova<br/>con almeno 1 conferma?"}
C -- "No" --> Z["Nulla da fare"]
C -- "Sì" --> D{"La maggioranza degli<br/>altri server conferma stessa<br/>moneta, stesso importo?"}
D -- "No / non rispondono" --> R["Accredito rinviato<br/>al prossimo controllo"]
D -- "Sì" --> E["Moneta registrata,<br/>saldo ricalcolato<br/>(deposit_credited)"]
B --> F{"Una moneta nota<br/>non spesa è sparita?"}
F -- "Sì, e la maggioranza degli<br/>altri server la conferma spesa" --> G["Segnata come spesa<br/>fuori dalla piattaforma<br/>(utxo_spent_externally)"]
B --> H{"Una moneta data per spesa<br/>fuori è ricomparsa?"}
H -- "Sì" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
Le vincite arrivano allo stesso modo: il pagamento del round è una normale
transazione verso l'indirizzo del vincitore, e viene accreditato come un
deposito. Lo stesso vale per il resto di scommesse e prelievi, che torna
all'indirizzo dell'utente.
- **[DEP-01]** Non c'è un importo minimo di deposito.
*Codice:* `app/deposits/service.py:credit_confirmed_utxos`
- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco
delle monete non spese (`listunspent`) dal server Electrum attivo ed esegue,
in quest'ordine: individuazione degli accrediti nuovi, reintegro delle
monete ricomparse, individuazione delle monete sparite; poi corrobora
accrediti e sparizioni presso gli altri server e solo dopo scrive il
risultato. Nessuna sessione del database resta aperta durante le chiamate
di rete.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[DEP-03]** **Una conferma basta.** Una moneta è candidata all'accredito
solo se è confermata (altezza maggiore di 0: le monete ancora in mempool
sono ignorate) e non è già registrata. Il rischio di una riorganizzazione
della catena a 1 conferma è accettato consapevolmente; non c'è logica di
annullamento (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo,
il server chiede agli *altri* server Electrum configurati se quella moneta
esiste davvero, con quell'importo, confermata
([ELC-17](connessione-electrum.md#5-il-quorum)). Un server malevolo non può
quindi inventarsi un deposito. Se la verifica non riesce l'accredito non è
perso, solo rimandato: la moneta viene riproposta all'aggiornamento
successivo (notifica o giro periodico), e nei log resta un avviso.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[DEP-05]** L'accredito registra la moneta (txid, posizione, importo,
altezza) in modo idempotente — una moneta già registrata non viene mai
accreditata due volte, e il database impone l'unicità della coppia
txid/posizione —, scrive per ciascuna un evento `deposit_credited`,
ricalcola il saldo e invia una notifica SSE.
*Codice:* `app/deposits/service.py:credit_confirmed_utxos` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-06]** **Monete spese altrove.** Una moneta registrata come non spesa
che non compare più nell'elenco è *candidata* alla spesa esterna (ad esempio
perché qualcuno ha usato la chiave privata esportata dall'admin). Le monete
spese dalla piattaforma stessa non sono mai candidate, perché vengono
segnate come spese già al momento della costruzione della transazione. Se
l'elenco è **completamente vuoto** mentre il database conosce monete non
spese, non si produce alcun candidato: una risposta vuota è trattata come
incompleta, non come una spesa di tutto.
*Codice:* `app/deposits/service.py:find_utxos_missing_from` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che la
maggioranza degli altri server conferma che la moneta non è più non spesa
([ELC-16](connessione-electrum.md#5-il-quorum)). Al salvataggio si
ricontrolla che la moneta sia ancora non spesa (nel frattempo potrebbe
averla spesa la piattaforma); la si segna con il marcatore di spesa esterna,
si scrive `utxo_spent_externally`, si ricalcola il saldo e si invia una
notifica SSE.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/deposits/service.py:mark_utxos_spent_externally` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
- **[DEP-08]** **Reintegro.** Una moneta segnata come spesa fuori dalla
piattaforma che ricompare nell'elenco viene reintegrata: il marcatore è
rimosso, si scrive `utxo_external_spend_reinstated`, si ricalcola il saldo e
si invia una notifica SSE. Il reintegro si basa sulla sola risposta del
server attivo, senza corroborazione.
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il
server notifica un cambiamento su un indirizzo osservato, alla
(re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la
stessa funzione di aggiornamento, così i due percorsi non possono
comportarsi diversamente.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
attende 300 secondi, poi riaggiorna **tutti** gli utenti, non solo quelli
che risultano non iscritti: serve a recuperare un'iscrizione persa
silenziosamente. Il giro salta se non c'è connessione, si interrompe se la
connessione cade a metà (la reiscrizione alla riconnessione copre tutti), e
l'errore su un utente viene registrato senza fermare gli altri.
*Codice:* `app/deposits/reconcile.py:DepositReconciler.run`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
## Il saldo mostrato
Scommesse e prelievi spendono monete **intere**: per pagare 10 PLM con una
moneta da 50, la moneta da 50 viene spesa tutta e 40 tornano all'utente come
resto (nella scommessa la commissione è dedotta dai 10 PLM, vedi
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede)), in una nuova
moneta che però è confermata solo al blocco successivo.
Per questo esistono due saldi, entrambi restituiti da `GET /users/me`:
| Campo | Cosa conta | A cosa serve |
|---|---|---|
| `balance_sats` | Solo monete confermate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa |
| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso | È ciò che l'interfaccia mostra |
Senza il secondo, subito dopo una scommessa l'utente vedrebbe sparire l'intero
resto (spesso molto più dei 10 PLM giocati) fino alla conferma. L'interfaccia
mostra il saldo in **verde** quando tutto è confermato e in **ambra** finché
c'è qualcosa in corso (`has_pending`).
- **[DEP-11]** Il saldo confermato (`balance_sats`, colonna
`cached_balance_sats`) è la somma delle monete registrate dell'utente non
segnate come spese. Viene ricalcolato nella stessa transazione del database
di ogni operazione che registra, spende, libera o reintegra monete.
*Codice:* `app/wallet/balance.py:recompute_balance` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-12]** Il saldo mostrato (`pending_balance_sats`) è il saldo
confermato più le uscite verso l'indirizzo dell'utente di ogni sua
scommessa o prelievo in stato `building` o `pending`, lette dalla
transazione corrente (aggiornata a ogni RBF). Un'uscita già registrata come
moneta (perché il resto è stato accreditato prima che la transazione
risultasse confermata) non viene contata due volte. `has_pending` è vero
finché esiste almeno una di queste transazioni.
*Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` ·
*Test:* `tests/unit/test_balance.py`
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete confermate e
non spese: il saldo mostrato è informativo, non spendibile. Se un utente
chiede un prelievo coperto dal saldo mostrato ma non ancora da quello
confermato, riceve l'errore `balance_pending_confirmation` ("attendi la
conferma") invece di un generico "saldo insufficiente" che contraddirebbe
ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo)).
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
+7 -5
View File
@@ -64,8 +64,10 @@ docker compose down # ferma e rimuove i container (i dati in ./data/
### ⚠️ Attenzione: riavvii automatici a metà round
`docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app:
se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso — se
il crash avviene mentre un round è in stato `closing`/`drawing`/`paying_out`,
lo scheduler non lo riprende al riavvio e il round resta bloccato (gap noto,
vedi "Known gaps" in `CLAUDE.md`). Non trattare questo setup come
"non supervisionato" finché quel gap non è risolto.
se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso: i
round in `closing` e `paying_out` riprendono da soli al riavvio, ma se il
crash avviene mentre un round è in `drawing`, lo scheduler non lo riprende e
il round resta bloccato (limite noto, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md); procedura in
[operativita.md](operativita.md)). Non trattare questo setup come
"non supervisionato" finché quel limite non è risolto.
+257
View File
@@ -0,0 +1,257 @@
# Scommesse e prelievi
Fasi 3 e 5 del [funzionamento generale](come-funziona.md): le due operazioni
con cui l'utente spende il proprio saldo. Hanno la stessa forma — una
transazione dal suo indirizzo verso una destinazione, con il resto che torna a
lui — e condividono le stesse regole di sicurezza.
I requisiti usano i prefissi `BET` (scommesse) e `WDR` (prelievi), secondo la
convenzione di [processo-di-modifica.md](processo-di-modifica.md). Il formato
delle risposte e il catalogo completo dei codici di errore sono in
[api.md](api.md); cosa succede a una transazione dopo l'invio in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
Costanti citate in questo documento (tutte in `app/wallet/psbt_builder.py`):
| Costante | Valore | Significato |
|---|---|---|
| `DUST_LIMIT_SATS` | 294 sat | Resto minimo di ogni transazione utente |
| `MAX_TX_INPUTS` | 50 | Monete massime in ingresso per scommessa o prelievo |
| `MAX_PARTICIPANTS_PER_ROUND` | 400 | Partecipanti massimi per round |
| `MAX_PAYOUT_TX_INPUTS` | 500 | Monete massime in ingresso per il pagamento del round |
## Scommessa
### Quando è accettata
Prima dei controlli valgono due condizioni di contorno:
- **[BET-01]** Se il server non è connesso alla rete, la scommessa è
rifiutata con `network_unavailable` (HTTP 503) prima di qualunque altro
controllo.
*Codice:* `app/api/routes/bets.py:create_bet`
- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio,
avviene tenendo il **blocco per-utente**, lo stesso usato dal prelievo
([WDR-01](#prelievo)).
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire`
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
rifiutata con il codice indicato (HTTP 400), prima che si muova qualunque
cifra:
| ID | Condizione | Codice di errore |
|---|---|---|
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-03](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
| **[BET-04]** | Il round è in stato `open` e la sua scadenza non è ancora passata | `round_closing` |
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa (`bet_amount_sats`) | `insufficient_balance` (parametro `required_sats`) |
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande | `too_many_inputs` (parametro `max_inputs`) |
| **[BET-09]** | Dopo la scommessa resta un resto di almeno 294 sat (`DUST_LIMIT_SATS`) | `balance_leaves_no_change` (parametro `required_extra_sats`) |
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
*Codice:* `app/bets/service.py:place_bet`, `app/rounds/service.py:round_accepts_bets`,
`app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
La **scadenza** è quella del round stesso (apertura + durata fissata
all'apertura), non il suo stato nel database: lo scheduler controlla ogni 5
secondi, quindi per qualche istante il round potrebbe risultare ancora
"aperto" oltre la scadenza.
- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della
richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la
transazione. L'iscrizione al round viene poi salvata nella stessa
transazione del database di un *compare-and-set* sulla riga del round
(`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in
quell'istante esatto, o il database segnala un conflitto di scrittura, tutto
viene annullato prima dell'invio e la scommessa è rifiutata con
`round_closing`. Così nessuna scommessa può finire nel montepremi senza
essere inclusa nell'estrazione.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
Il limite di **400 partecipanti** esiste perché il pagamento deve spendere una
moneta del montepremi per ogni scommessa, e una singola transazione di
pagamento può contenerne al massimo 500 (`MAX_PAYOUT_TX_INPUTS`); la
differenza lascia spazio ai resti accumulati nel montepremi dai round
precedenti. Bloccare il 401° giocatore all'ingresso garantisce che ogni round
si possa sempre pagare.
### Cosa succede
```mermaid
flowchart TB
A["Richiesta di scommessa"] --> N{"Connessi<br/>alla rete?"}
N -- "No" --> N1["network_unavailable (503)"]
N -- "Sì" --> B["Blocco per-utente acquisito"]
B --> C["Controlli (tabella sopra)"]
C --> D["Scelta delle monete,<br/>dalla più grande"]
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi<br/>resto → indirizzo utente"]
E --> CAS{"Scadenza non passata e<br/>round ancora 'open'<br/>(compare-and-set)?"}
CAS -- "No" --> CAS1["Annullato prima dell'invio:<br/>round_closing"]
CAS -- "Sì" --> F["Salvataggio PRIMA dell'invio:<br/>monete segnate come spese,<br/>partecipante 'building'"]
F --> G{"La rete accetta<br/>l'invio?"}
G -- "No" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
G -- "Sì" --> I["Partecipante 'broadcast'<br/>(in attesa)"]
I --> J{"Confermata entro<br/>il tempo limite?"}
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente"] --> J
J -- "Mai, e la rete non<br/>la conosce più" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
J -- "Sì" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
```
- **[BET-12]** La transazione spende monete **intere** dell'utente, scelte
dalla più grande; tutti gli ingressi segnalano la sostituibilità (BIP125,
`RBF_SEQUENCE`). Ha due uscite: il costo meno la commissione verso
l'indirizzo del montepremi, e il resto (monete spese meno il costo) verso
l'indirizzo dell'utente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py`
- **[BET-13]** La **commissione di rete** è la dimensione stimata della
transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arrivano
10 PLM meno la commissione, e l'importo registrato per il partecipante
(`bet_amount_sats`) è proprio questo netto. Per questo il montepremi si
calcola sommando gli importi effettivi delle scommesse, non moltiplicando il
numero di giocatori per il costo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_rounds_route.py`
- **[BET-14]** Il **resto** torna sempre allo stesso indirizzo dell'utente ed è
sempre di almeno 294 sat: senza un'uscita di resto non si potrebbe aumentare
la commissione in caso di ritardo. A differenza del prelievo, la scommessa
non riduce mai il proprio importo per far posto al resto: la rifiuta
([BET-09]). Il saldo di un utente che scommette deve quindi essere
strettamente maggiore del costo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
- **[BET-15]** **Prima si scrive, poi si invia.** Prima dell'invio, in
un'unica transazione del database, le monete spese vengono segnate come tali
(con il txid della scommessa), il saldo viene ricalcolato, e si registrano il
partecipante (stato `building`, con l'orario di invio che ne determina
l'ordine nell'estrazione) e la transazione in corso (stato `building`).
Dettagli in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia).
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-16]** Se l'invio fallisce per qualunque motivo, tutto torna com'era:
monete liberate, partecipante e transazione in corso **cancellati**, saldo
ricalcolato, evento `bet_broadcast_failed` nel registro di audit, notifica
SSE; la risposta è `broadcast_failed` (HTTP 502).
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-17]** Se l'invio riesce, il partecipante passa a `broadcast`, la
transazione in corso a `pending`, si scrive `bet_placed` (txid e importo
netto) e si invia una notifica SSE. La risposta è HTTP 201 con `round_id`,
`bet_txid`, `bet_amount_sats` e `status`.
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-18]** Alla prima conferma il partecipante passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse
**confermate** partecipano all'estrazione; il round, allo scadere, aspetta
che tutte quelle già inviate confermino (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)).
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
- **[BET-19]** Tutte le scommesse hanno **la stessa probabilità** di vincere,
indipendentemente dall'importo netto e da tutto il resto
([RND-17](ciclo-di-vita-del-round.md#3--estrazione)).
*Codice:* `app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_draw.py`
## Prelievo
Il prelievo (`POST /withdrawals`, con `external_address` e `amount_sats`) è
l'**unico** modo per portare fondi fuori dalla piattaforma, verso un indirizzo
esterno scelto dall'utente.
- **[WDR-01]** Come per la scommessa, un prelievo senza connessione alla rete
è rifiutato con `network_unavailable` (HTTP 503), e tutta l'elaborazione
avviene tenendo il **blocco per-utente**.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire`
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
| ID | Condizione | Codice di errore |
|---|---|---|
| **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` |
| **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` |
| **[WDR-04]** | L'importo è almeno pari al costo corrente di una scommessa (`bet_amount_sats`; non esiste un minimo separato) | `amount_below_minimum` (parametro `minimum_sats`) |
| **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) |
| **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) |
| **[WDR-07]** | L'importo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/wallet/address.py:is_valid_plm_address`,
`app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
La struttura è identica alla scommessa: transazione firmata dall'indirizzo
dell'utente verso l'indirizzo esterno, resto all'utente, **commissione dedotta
dall'importo prelevato**, salvataggio prima dell'invio, annullamento completo
se la rete rifiuta, RBF se non conferma in tempo.
- **[WDR-08]** La transazione spende monete intere dell'utente (dalla più
grande, ingressi sostituibili BIP125); all'indirizzo esterno arriva
l'importo meno la commissione di rete, il resto torna all'indirizzo
dell'utente.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_withdrawals.py`
- **[WDR-09]** **Resto garantito.** Se l'importo richiesto lascerebbe un resto
inferiore a 294 sat — tipicamente il prelievo dell'intero saldo —, l'importo
viene ridotto quanto basta perché il resto sia esattamente 294 sat, che
restano sul conto dell'utente. Chi preleva tutto riceve quindi il saldo meno
294 sat meno la commissione. Il database registra sia l'importo richiesto
(`amount_requested_sats`) sia quello effettivamente inviato al destinatario,
al netto della commissione (`amount_sent_sats`).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database, le
monete spese vengono segnate come tali, il saldo ricalcolato, e si
registrano il prelievo e la transazione in corso, entrambi in stato
`building`.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-11]** Se l'invio fallisce, le monete vengono liberate e il saldo
ricalcolato, ma — a differenza della scommessa — il prelievo **non viene
cancellato**: resta come `failed` (senza txid), così come la transazione in
corso (con il motivo in `failure_reason`); si scrive
`withdrawal_broadcast_failed`, si invia una notifica SSE e la risposta è
`broadcast_failed` (HTTP 502).
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-12]** Se l'invio riesce, il prelievo passa a `broadcast`, la
transazione in corso a `pending`, si scrive `withdrawal_sent` (txid, importo
inviato, indirizzo esterno) e si invia una notifica SSE. La risposta è
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e `status`.
Alla prima conferma il prelievo passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
*Test:* `tests/unit/test_withdrawals.py`
## Scommessa e prelievo insieme
Scommessa e prelievo spendono le stesse monete, quindi non devono mai sceglierne
una in comune. Questo è garantito da due meccanismi:
1. un **blocco per-utente** ([BET-02](#quando-è-accettata),
[WDR-01](#prelievo)): la costruzione di una scommessa e quella di un
prelievo dello stesso utente non possono mai essere in corso nello stesso
momento;
2. la selezione delle monete **salta quelle già segnate come spese**, anche se
la transazione che le spende non è ancora confermata
([BET-07](#quando-è-accettata), [WDR-05](#prelievo)).
- **[WDR-13]** Non c'è altro vincolo: un prelievo è accettato
anche mentre una scommessa attende conferma (e viceversa), purché le monete
confermate e ancora libere lo coprano. Congelare il resto del saldo per un blocco non
aggiungerebbe alcuna sicurezza.
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_withdrawals.py`
Il blocco per-utente vive nella memoria del processo: vale finché il server è
un solo processo (vedi [architettura.md](architettura.md) e
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
Cosa succede a una transazione dopo l'invio — conferma, RBF, abbandono — è
descritto in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
+9 -5
View File
@@ -35,8 +35,11 @@ cp .env.example .env
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`,
`MASTER_KEY_PATH`) hanno default sensati in `.env.example`.
Le chiavi Electrum (`ELECTRUM_HOST`/`PORT`/`USE_SSL`) hanno default sensati in
`.env.example`. `DATABASE_URL` e `MASTER_KEY_PATH` non sono elencate lì:
hanno un default in `app/config.py`, e dentro il container vengono comunque
sovrascritte da `docker-compose.yml` per puntare a `./data/` (elenco completo
delle variabili in [architettura.md](architettura.md)).
`ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo
su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API,
@@ -119,9 +122,10 @@ 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 [guida-admin.md](guida-admin.md). I
payout si rifiutano di partire finché non è impostato.
Prima che si possa aprire il primo round, un admin deve impostare
`fee_address` tramite il pannello admin o l'API — vedi
[guida-admin.md](guida-admin.md). Finché non è impostato **nessun round si
apre**, perché il pagamento non si potrebbe costruire.
A questo punto l'istanza è pronta per essere avviata — continua con
[running-the-server.md](running-the-server.md).
+515
View File
@@ -0,0 +1,515 @@
# Sicurezza
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e
quali rischi accetta consapevolmente. Il documento descrive il codice così
com'è: dove un meccanismo manca, lo dice.
Per la difesa contro server Electrum ostili (validazione degli header,
quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md);
per gli endpoint e i codici di errore [api.md](api.md); per l'elenco degli
eventi di audit [audit-log.md](audit-log.md); per backup e procedure
operative [operativita.md](operativita.md); per le decisioni di progetto e i
limiti accettati [decisioni-e-limiti.md](decisioni-e-limiti.md).
## Modello di fiducia
La piattaforma è **custodiale**: tutte le chiavi private sono generate e
conservate dal server. L'utente non controlla nulla finché non preleva.
| Soggetto | Fiducia | Perché |
|---|---|---|
| Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. |
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra (`.env`). |
| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. |
| Server Electrum | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum: vedi [connessione-electrum.md](connessione-electrum.md#5-il-quorum). |
| Rete e client HTTP | Nessuna | TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy. |
| Caddy (reverse proxy) | Totale | È l'unico proxy davanti all'app, e l'unica fonte di `X-Forwarded-For` di cui l'app si fida. |
- **[SEC-01]** Tutte le chiavi private (utenti e montepremi) derivano da
un'unica master xprv generata e custodita dal server; nessuna chiave è mai
generata o conservata lato client.
*Codice:* `app/wallet/hd.py:generate_master_key`, `app/wallet/hd.py:_load_account_key`
## Inventario dei segreti
| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione |
|---|---|---|---|---|---|
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza `XPRV_ENCRYPTION_KEY`; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
| `XPRV_ENCRYPTION_KEY` | `.env` (variabile d'ambiente del container) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale: decifrare con la chiave vecchia, impostare la nuova, `encrypt_master_key.py --overwrite`. Non esiste uno script dedicato |
| `JWT_SECRET` | `.env` | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
| `ADMIN_TOKEN` | `.env`; nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
- **[SEC-02]** I segreti (`XPRV_ENCRYPTION_KEY`, `JWT_SECRET`, `ADMIN_TOKEN`)
stanno solo in `.env`, mai nel database o nel repository; `.env`, i file
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
git sia dall'immagine Docker.
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
- **[SEC-03]** Tutti i parametri operativi stanno nel database
(`round_config`); in `.env` restano solo segreti e collegamenti
infrastrutturali (database, percorso della master key, server Electrum,
documentazione API). Cambiare un valore di `.env` richiede un riavvio.
*Codice:* `app/config.py:Settings`
`.env.example` documenta le chiavi necessarie e il comando per generarle;
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
sovrascritte da `docker-compose.yml`.
## Ciclo di vita della master key
```mermaid
flowchart TD
G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
I["encrypt_master_key.py<br/>xprv importata (input nascosto)"] --> E
E["Cifratura Fernet<br/>con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc<br/>./data/keys/")]
F --> L["Primo uso a runtime:<br/>decifratura e derivazione<br/>di m/84'/746'/0'"]
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
L --> P["Montepremi<br/>m/84'/746'/0'/1/0"]
F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]
```
### Generazione
- **[SEC-04]** La master key si crea solo con uno script manuale, mai tramite
API: `generate_master_key.py` genera un seed casuale di 32 byte, ne ricava
la xprv (versione PLM mainnet), la cifra e la scrive in `MASTER_KEY_PATH`.
Rifiuta di sovrascrivere un file esistente; entrambi gli script di
creazione rifiutano di partire se `XPRV_ENCRYPTION_KEY` è vuota.
*Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py`
- **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la
legge con input nascosto (`getpass`), verifica che sia una chiave estesa
valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema.
Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di
versione xprv di Bitcoin. Sovrascrive un file esistente solo con
`--overwrite`.
*Codice:* `scripts/encrypt_master_key.py`
### Cifratura a riposo
- **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con
**Fernet** (AES-128-CBC + HMAC-SHA256) usando `XPRV_ENCRYPTION_KEY`. La xprv
in chiaro non viene mai scritta su disco dal server.
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv`
La cifratura protegge da una copia del solo file (un backup di `data/`
finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di
decifratura sta nel `.env` accanto. Gli script non impostano permessi
restrittivi sul file scritto: valgono quelli predefiniti del sistema.
### Derivazione
- **[SEC-07]** Gli indirizzi utente seguono BIP84 con coin type 746:
`m/84'/746'/0'/0/<derivation_index>`, un indice per utente, P2WPKH con HRP
`plm`.
*Codice:* `app/wallet/hd.py:derive_user_key`, `app/wallet/hd.py:derive_user_address`,
`app/wallet/plm_network.py` ·
*Test:* `tests/unit/test_hd.py`
- **[SEC-08]** La chiave del montepremi è `m/84'/746'/0'/1/0` (ramo 1
dell'account, indice 0): sta nella stessa master key, non è un segreto
separato. Non esiste un endpoint che ne esporti la chiave privata.
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`
### Caricamento a runtime
- **[SEC-09]** La master key viene letta e decifrata **al primo uso** (la
prima derivazione, tipicamente una registrazione o il primo pagamento), non
all'avvio. Da quel momento la chiave dell'account `m/84'/746'/0'` resta in
memoria nel processo per tutta la sua durata; la root xprv non viene
conservata.
*Codice:* `app/wallet/hd.py:_load_account_key`
### Recupero
- **[SEC-10]** `decrypt_master_key.py` decifra e stampa la xprv in chiaro, solo
dopo una conferma esplicita (`yes`) e un avviso sul terminale. Se il
percorso di `MASTER_KEY_PATH` non esiste usa `./data/keys/master.xprv.enc`.
Non esiste un equivalente via API o pannello.
*Codice:* `scripts/decrypt_master_key.py`
## Regole di avvio
- **[SEC-11]** Il container rifiuta di partire se il file della master key non
esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni.
*Codice:* `Dockerfile` (comando `CMD`)
- **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha
meno di 32 caratteri o se `XPRV_ENCRYPTION_KEY` è vuota; tutti i problemi
sono riportati insieme in un unico `ConfigError`. Il controllo è nel
lifespan, non nella classe `Settings`, perché test e script importano la
configurazione senza avere segreti reali.
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
*Test:* `tests/unit/test_config.py`
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
risulta semplicemente chiuso (vedi [SEC-24]).
*Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py`
Il controllo di avvio verifica che `XPRV_ENCRYPTION_KEY` non sia vuota, non
che sia una chiave Fernet valida né che decifri davvero il file: una chiave
malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]).
## Autenticazione degli utenti
Il flusso di registrazione e login è descritto in
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione);
qui ci sono le regole di sicurezza.
### Password
- **[SEC-14]** Le password sono salvate come hash **Argon2id** con i parametri
predefiniti di `argon2-cffi` (`PasswordHasher()` senza argomenti; con la
versione installata 25.1: time cost 3, memoria 64 MiB, parallelismo 4, sale
16 byte, hash 32 byte). Mai in chiaro, mai recuperabili.
*Codice:* `app/auth/security.py:hash_password` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-15]** Ogni hash e verifica chiamati da codice asincrono passano da
`hash_password_async`/`verify_password_async`, che eseguono Argon2 in un
thread del threadpool: un login non deve bloccare l'event loop e con esso le
attività in background. Le versioni sincrone restano per test e script.
*Codice:* `app/auth/security.py:hash_password_async`, `app/auth/security.py:verify_password_async` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-16]** Qualunque errore di verifica (password sbagliata, hash
illeggibile) vale come "password errata", mai come errore del server; un
hash illeggibile viene anche registrato nel log.
*Codice:* `app/auth/security.py:verify_password` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-17]** Lunghezza minima della password: 8 caratteri
(`MIN_PASSWORD_LENGTH`), la stessa costante per registrazione e cambio
password. Alla registrazione la password ha anche un massimo di 256
caratteri; il cambio password e il login non impongono un massimo.
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
`app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
- **[SEC-18]** Lo username è di 3–32 caratteri, solo `A-Z a-z 0-9 _ . -`, e
**non distingue maiuscole e minuscole**: l'unicità è garantita da un indice
unico su `lower(username)` e registrazione e login confrontano
`lower(...)`. Il nome resta salvato come digitato.
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:register`,
`app/auth/routes.py:login`, `app/db/models.py:User` ·
*Test:* `tests/unit/test_auth.py`
- **[SEC-19]** Il login risponde allo stesso modo (401 `invalid_credentials`)
sia per uno username inesistente sia per una password errata, e i due casi
alimentano lo stesso contatore di tentativi. Nota: se lo username non
esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi
di risposta diversi. La registrazione, invece, rivela per costruzione se uno
username è occupato (409 `username_taken`).
*Codice:* `app/auth/routes.py:login` ·
*Test:* `tests/unit/test_auth.py`
- **[SEC-20]** Non esiste recupero della password né reset self-service
(nessuna email è raccolta). Un utente autenticato può *cambiarla* solo
fornendo quella attuale; altrimenti serve il reset dall'operatore
([SEC-27]).
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
### Token di sessione (JWT)
- **[SEC-21]** Login e registrazione restituiscono un JWT **HS256** firmato con
`JWT_SECRET`, valido `jwt_expire_minutes` (predefinito 1440 = 24 ore). Le
claim sono `sub` (id utente), `tv` (versione del token) ed `exp`. La
decodifica accetta solo l'algoritmo configurato. Il client lo invia come
`Authorization: Bearer …` e lo conserva in `localStorage`.
*Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-22]** I token sono **revocabili**: ogni richiesta autenticata
confronta la claim `tv` con `User.token_version` e rifiuta (401
`session_expired`) un token con una versione diversa, un token non valido o
scaduto, o un utente inesistente. Il cambio password self-service e il reset
dell'operatore incrementano `token_version`, invalidando ogni sessione
precedente; il cambio password restituisce un token nuovo, così la sessione
corrente resta attiva. Un token privo di `tv` (emesso prima della sua
introduzione) vale come `tv = 0`.
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
personalizzare la risposta: un token assente, non valido o superato li fa
trattare come anonimi, senza errore.
*Codice:* `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_rounds_route.py`, `tests/unit/test_bug_reports.py`
## Autenticazione dell'operatore
Il pannello `/admin` è una pagina statica pubblica; la protezione sta sugli
endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
- **[SEC-24]** Ogni endpoint `/admin/*` confronta `X-Admin-Token` con
`ADMIN_TOKEN` in tempo costante (`secrets.compare_digest` sui byte UTF-8,
così un token con caratteri non ASCII dà 403 e non 500). Se `ADMIN_TOKEN` è
vuoto **ogni** richiesta è rifiutata con 403, prima del confronto (che su due
stringhe vuote darebbe esito positivo).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
l'ha fatto. Non c'è limite di tentativi né lunghezza minima imposta su
`ADMIN_TOKEN`. Nel browser il token è tenuto in `sessionStorage`, quindi si
perde alla chiusura della scheda.
*Codice:* `app/api/routes/admin.py:require_admin`, `app/static/admin.js`
Il token protegge: configurazione e pausa/ripresa della lotteria, elenco
utenti con saldi, export della chiave privata, reset della password, storico
dei round, transazioni pendenti, audit log, bug report. L'elenco completo
degli endpoint è in [api.md](api.md).
- **[SEC-26]** `GET /admin/users/{id}/privkey` esporta la chiave privata WIF
dell'utente: è **intenzionale** (il server ha già la master key; l'endpoint
espone solo ciò che l'operatore potrebbe comunque ricavare). Ogni accesso
scrive `admin_privkey_accessed` nell'audit log (con l'id utente, mai la
chiave). Il pannello mostra la WIF con `textContent`, senza conservarla.
*Codice:* `app/api/routes/admin.py:user_privkey`, `app/wallet/hd.py:derive_user_wif` ·
*Test:* `tests/unit/test_admin.py`
- **[SEC-27]** `POST /admin/users/{id}/reset-password` imposta una password
casuale (`secrets.token_urlsafe(12)`, 16 caratteri), la restituisce **una
sola volta** perché l'operatore la comunichi, incrementa `token_version`
([SEC-22]) e scrive `admin_password_reset` nell'audit log (senza la
password).
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_admin.py`
- **[SEC-28]** Ogni azione dell'operatore che cambia lo stato lascia una
traccia nell'audit log: modifica della configurazione (`config_updated`,
con valori prima/dopo, niente se nulla cambia), pausa e ripresa
(`lottery_paused`/`lottery_resumed`, e `paused` non è modificabile da
`PUT /admin/config`), cambio di stato di un bug report
(`bug_report_status_changed`). Il catalogo completo è in
[audit-log.md](audit-log.md).
*Codice:* `app/api/routes/admin.py:update_config`, `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery`, `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
- **[SEC-29]** `fee_address`, se modificato dall'operatore, deve essere un
indirizzo PLM bech32 valido (checksum e HRP `plm`): un indirizzo di un'altra
catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli
altri parametri di `PUT /admin/config` hanno limiti minimi e massimi.
*Codice:* `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
## Limiti di frequenza
Tutti i limitatori sono **in memoria, nel singolo processo**: si azzerano a
ogni riavvio e non funzionerebbero con più worker.
| Limitatore | Chiave | Regola | Dove |
|---|---|---|---|
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
- **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori
(username o IP) è in blocco, anche con la password giusta; la risposta è
429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento
alimenta entrambi i limitatori; un successo azzera solo quello dello
username.
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter`,
`app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
- **[SEC-31]** La registrazione è una **quota**, non un freno sui fallimenti:
5 account per IP ogni 3600s (finestra mobile). Il controllo avviene prima
dell'hash Argon2; un tentativo che non crea l'account (username occupato,
input non valido) non consuma la quota. Oltre la quota: 429 `rate_limited`
con l'attesa esatta fino all'uscita dalla finestra dell'account più vecchio.
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi
più recenti.
*Codice:* `app/auth/rate_limit.py:RateLimiter._prune`, `app/auth/rate_limit.py:RollingQuota._prune` ·
*Test:* `tests/unit/test_rate_limit.py`
- **[SEC-33]** Il canale SSE accetta al massimo 5 stream contemporanei per IP
(il sesto chiude il più vecchio dello stesso IP invece di essere
rifiutato) e 500 in totale (oltre, 503; il browser ripiega sul polling).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, cambio
password (tentativi sulla password attuale compresi), endpoint admin
(tentativi sul token compresi), invio di bug report (anche anonimi), QR,
`GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi
sono serializzati per utente dal blocco per-utente (vedi
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)),
che non è un limite di frequenza.
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
`app/api/routes/users.py:change_password`, `app/api/routes/bug_reports.py:create_bug_report`
## Indirizzo IP del client
Ogni controllo basato sull'IP (limitatori di login e registrazione, limite
SSE per IP) usa la stessa funzione.
- **[SEC-35]** L'IP del client è l'**ultimo** elemento di `X-Forwarded-For`
(quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il
client può falsificare. Senza l'header si usa l'indirizzo della connessione,
e in mancanza anche di quello `unknown`.
*Codice:* `app/api/client_ip.py:client_ip` ·
*Test:* `tests/unit/test_client_ip.py`
- **[SEC-36]** Caddy **sovrascrive** `X-Forwarded-For` con l'indirizzo reale
del peer (`header_up X-Forwarded-For {remote_host}`) invece di accodarlo a
quello inviato dal client. Le due difese ([SEC-35] e questa) reggono
ciascuna da sola.
*Codice:* `Caddyfile` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`
- **[SEC-37]** Il container `app` non è pubblicato sull'host: la porta 8123
è solo dichiarata con `expose` sulla rete interna di compose, e l'unico ingresso
dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile
l'ultimo hop di `X-Forwarded-For`.
*Codice:* `docker-compose.yml`
## TLS e header HTTP
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato ottiene e
rinnova un certificato Let's Encrypt; con il valore predefinito
`localhost` usa un certificato autofirmato della propria CA interna.
*Codice:* `Caddyfile`, `docker-compose.yml`
- **[SEC-39]** Caddy aggiunge a ogni risposta questi header:
```
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
```
*Codice:* `Caddyfile` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`
La CSP è la principale difesa contro l'XSS, rilevante perché il JWT sta in
`localStorage` e uno script iniettato potrebbe leggerlo. `script-src` e
`style-src` richiedono però `'unsafe-inline'`: entrambe le SPA usano gestori
`onclick` e attributi `style=""` inline ovunque, e toglierli è un refactoring
a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
`style.css`/`admin.css`). I dati provenienti dal server inseriti con
`innerHTML` nei due pannelli passano da una funzione `escapeHtml`.
- **[SEC-40]** Le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono
servite con `Cache-Control: no-store`. Non è configurato alcun middleware
CORS: il browser consente le chiamate all'API solo dalla stessa origine.
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`
## Superficie esposta
- **[SEC-41]** La documentazione interattiva dell'API (Swagger `/docs`, ReDoc
`/redoc`, `/openapi.json`) è **disattivata** salvo `ENABLE_API_DOCS=true`,
da usare solo in sviluppo: elencherebbe l'intera API, admin compreso.
*Codice:* `app/config.py:Settings`, `app/main.py` ·
*Test:* `tests/unit/test_docs_disabled.py`
- **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore
standard (`internal_error`) e **senza** il testo dell'eccezione, che resta
solo in `logs/app.log`.
*Codice:* `app/main.py:log_unhandled_exception`
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
## Validazione degli input
- **[SEC-43]** Un indirizzo fornito dall'esterno (destinazione di un prelievo,
`fee_address`, QR) è accettato solo se inizia con `plm1` **e** supera la
decodifica bech32 con checksum: un indirizzo valido di un'altra catena
(`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato.
*Codice:* `app/wallet/address.py:is_valid_plm_address`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py`
- **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un `<img>`, che non
può portare il token) ma limitato: rifiuta con 400 `invalid_address` un
indirizzo più lungo di 100 caratteri o non valido ([SEC-43]) prima di
generare qualunque immagine; il rendering avviene fuori dall'event loop ed è
memorizzato per al più 512 indirizzi.
*Codice:* `app/api/routes/qr.py:address_qr`, `app/api/routes/qr.py:_render_png` ·
*Test:* `tests/unit/test_qr.py`
- **[SEC-45]** Un bug report ha una descrizione obbligatoria di 1–2000
caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256
caratteri. Può essere inviato anche da non autenticati; se il mittente è
autenticato viene associato al suo account, e solo lui vede i propri report
(`GET /bug-reports/mine`, autenticato).
*Codice:* `app/api/routes/bug_reports.py:BugReportCreate`,
`app/api/routes/bug_reports.py:create_bug_report`,
`app/api/routes/bug_reports.py:list_my_bug_reports` ·
*Test:* `tests/unit/test_bug_reports.py`
- **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log,
transazioni pendenti, bug report) accettano `limit` tra 1 e 500. L'elenco
utenti non è paginato.
*Codice:* `app/api/routes/admin.py:list_rounds`, `app/api/routes/admin.py:list_audit_log`,
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
`app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza
SQL composto da stringhe.
## Dati conservati e log
- **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash
Argon2 della password, indice di derivazione, indirizzo, saldo in cache,
`token_version`, data di creazione. **Nessuna email** né altro dato
anagrafico. L'unico campo libero che può contenere un recapito è il
`contact` facoltativo di un bug report. Gli indirizzi IP dei client non
sono salvati nel database: l'app li usa solo nella memoria dei limitatori.
*Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport`
La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password
(nemmeno quella generata dal reset), né WIF, né token, né la master key. Il
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file) contiene i
messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn
(indirizzo del peer diretto, metodo, percorso, esito: nessun percorso
contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono
registrati). L'unico script che stampa un segreto è
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
generate dal reset compaiono solo nella risposta HTTP all'operatore.
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
`app/api/routes/admin.py:reset_user_password`
## Rischi accettati
Limiti noti e voluti, descritti in dettaglio in
[decisioni-e-limiti.md](decisioni-e-limiti.md):
- **Custodia totale.** Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i
fondi; la cifratura a riposo non protegge da chi ha accesso all'host.
- **Token admin unico e condiviso**, senza identità per operatore né limite
di tentativi: una fuga ha il raggio d'azione massimo.
- **JWT in `localStorage`**, quindi leggibile da un eventuale XSS; la CSP lo
mitiga ma ammette script inline.
- **Limiti di frequenza solo su login, registrazione e SSE**, in memoria e per
singolo processo.
- **Nessuna corroborazione se `ELECTRUM_FALLBACK_SERVERS` è vuoto**: il server
Electrum principale diventa fidato (vedi
[connessione-electrum.md](connessione-electrum.md#5-il-quorum)).
- **Una sola conferma** per ogni transazione, con il rischio di
riorganizzazione accettato e nessuna logica di rollback.
- **La master key non è ruotabile**: gli indirizzi degli utenti sono
permanenti.
+213
View File
@@ -0,0 +1,213 @@
# Storico dei bug
Registro di tutti i difetti numerati `B-nn` trovati finora: cosa non andava,
quanto era grave, quale commit lo ha corretto e quale test impedisce che
ritorni. È la memoria storica del progetto; le regole valide oggi stanno negli
altri documenti della specifica, a cui la colonna **Area** rimanda per
prefisso (vedi [processo-di-modifica.md](processo-di-modifica.md#prefissi)).
Questo file non contiene requisiti e quindi non ha identificativi propri.
## Convenzione
- **Un difetto nuovo prende il prossimo numero libero**, senza riusare né
rinumerare quelli esistenti. **Il prossimo numero libero è `B-73`.**
- **Un commit dedicato per ogni difetto**, con il numero nel messaggio (ad
esempio `Snapshot a round's timing when it opens (B-61)`), così
`git log --all --grep 'B-nn'` ritrova sempre come è stato chiuso.
- **Un test di regressione per ogni difetto**, che cita il numero nella sua
docstring o in un commento. Dove un test automatico non è possibile (resa
grafica nel browser, layout dell'immagine Docker) lo si dichiara, come nelle
righe marcate *manuale* qui sotto.
- **Il marcatore `B-nn` va anche nel codice**, in un commento accanto alla
correzione: le centinaia di riferimenti sparsi in codice e test sono puntatori verso
questo registro e verso la storia git.
- **Prima la specifica, se era sbagliata.** Una correzione che riporta il
codice a ciò che i documenti già dicevano è solo un `B-nn`. Se invece il
difetto rivela che la specifica era sbagliata o incompleta, si corregge prima
il requisito in `docs/`, come per qualunque altra modifica
([processo-di-modifica.md](processo-di-modifica.md#il-ciclo-di-una-modifica)).
- I limiti accettati **per scelta** non sono bug e non prendono un `B-nn`:
stanno in [decisioni-e-limiti.md](decisioni-e-limiti.md).
### Da dove vengono questi dati
I difetti sono stati tracciati in un file `BUGS.md` alla radice del
repository, riscritto a ogni audit:
| Versione | Commit | Contenuto |
|---|---|---|
| Audit 2026-07-26 | `fb734bb`, `9b6a4a2`, `845ba98` | B-01 … B-24, con gravità, correzione proposta e, nell'ultima versione, la nota **Fixed** di ciascuno |
| Audit 2026-07-27 | `43d2321`, `7224ca0` | B-25 … B-49 con gravità |
| Chiusura | `0d2fef6` | file cancellato quando entrambe le liste erano vuote |
| Audit 2026-08-03 | `99d7a1e` e successivi | B-52 … B-72, ogni voce rimossa al momento della correzione |
Per leggere una versione: `git show <commit>:BUGS.md`. Il `BUGS.md` attuale,
presente nel ramo `main`, contiene solo le voci ancora aperte (B-71 e B-72).
La gravità è quella registrata in `BUGS.md` al momento dell'audit. Il terzo
audit la definiva così, e la stessa scala vale in sostanza anche per i primi
due: **critica** = denaro bloccato o perso, oppure la lotteria si ferma;
**alta** = un controllo di sicurezza che non regge, o un difetto con effetti
seri sul denaro; **media** = comportamento errato con danni circoscritti;
**bassa** = igiene, oppure documentazione non allineata al codice.
---
## Audit del 2026-07-26 (B-01 … B-24)
Primo audit completo del codice (commit di partenza `d4e0974`, 79 test verdi,
nessuno dei quali copriva questi casi). Le correzioni sono raggruppate per
tema in otto commit, fra `cc88763` e `dc4d576`, e il loro esito è registrato
in `845ba98`. La suite passò da 79 a 139 test.
La causa comune dei quattro difetti critici principali (B-01 … B-04) era che
**il codice considerava un invio come definitivo e la connessione come
infallibile**: non esisteva alcuna riconciliazione fra database e catena.
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-01 | Una connessione Electrum caduta bloccava per sempre l'intero server: nessuna riconnessione, nessun timeout sulle richieste, nessun keepalive | Critica | `7c4e998` Survive a dropped Electrum connection, and fall back to other servers | `test_electrum_client.py::test_request_times_out_instead_of_hanging_forever`, `::test_wait_closed_resolves_when_the_read_loop_dies`; `test_electrum_listener.py::test_rotates_to_the_next_server_after_a_failed_session` | ELC |
| B-02 | Un RBF su una scommessa lasciava `RoundParticipant.bet_txid` sul vecchio txid, e il round restava in `closing` per sempre | Critica | `d528c5b` Let the system recover from a broadcast that never confirms | `test_confirmation.py::test_bet_confirms_after_an_rbf_bump_changed_the_txid`; `test_broadcast.py::test_bump_fee_retargets_every_stored_txid` | TX, BET |
| B-03 | Un solo txid irrisolvibile interrompeva il rilevamento delle conferme per tutte le altre transazioni | Critica | `d528c5b` | `test_confirmation.py::test_one_unresolvable_candidate_does_not_block_the_others` | TX |
| B-04 | Nessuna riconciliazione: una transazione scartata dalla rete congelava per sempre le monete spese e i fondi dell'utente | Critica | `cc88763` Make "one active round" a DB invariant and record why a tx failed; `d528c5b` | `test_reconcile.py` | TX |
| B-05 | `fee_address` e parametri numerici della configurazione non validati: un valore errato bloccava il pagamento o mandava la commissione a uno script non spendibile | Critica | `daf66fd` Fix the round-open race, the advertised jackpot, and payout error handling; `85dce22` Validate admin config and registration input, and log config changes | `test_admin.py::test_config_rejects_unusable_values` | API, RND |
| B-06 | Venivano create uscite di resto sotto la soglia di polvere, rendendo la transazione non inoltrabile | Alta | `b4d7038` Leave dust-sized change to the fee instead of creating it | `test_psbt_builder.py::test_a_below_dust_change_output_is_never_created` | BET, WDR |
| B-07 | Un invio rifiutato dalla rete arrivava al client come HTTP 500, fuori dal contratto d'errore strutturato | Alta | `d528c5b` | `test_bets.py::test_failed_broadcast_reports_the_broadcast_failed_code`; `test_withdrawals.py::test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins` | BET, WDR, API |
| B-08 | La scommessa veniva inviata prima di essere salvata: un errore al commit perdeva il denaro senza lasciare traccia | Alta | `d528c5b` | `test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `::test_failed_broadcast_leaves_nothing_behind` | BET, TX |
| B-09 | Una corsa permetteva due round `open` contemporanei, che bloccavano per sempre i round successivi | Alta | `cc88763`; `daf66fd` | `test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round`, `::test_the_database_refuses_a_second_active_round` | RND, DAT |
| B-10 | `PUT /admin/config` non scriveva nulla nel registro di audit | Alta | `85dce22` | `test_admin.py::test_config_update_is_audit_logged`, `::test_pause_cannot_be_toggled_through_the_config_endpoint` | AUD |
| B-11 | Il montepremi mostrato non era l'importo che il vincitore avrebbe ricevuto | Alta | `daf66fd` | `test_rounds_route.py::test_jackpot_comes_from_the_participants_actual_bets` | RND, API |
| B-12 | La registrazione non validava né username né password, a differenza del cambio password | Alta | `85dce22`; `dc4d576` Format amounts, guard the loading state, and translate the new errors | `test_users.py::test_register_rejects_weak_credentials` | REG, SEC |
| B-13 | `verify_password` trasformava un hash salvato malformato in un 500 invece che in un 401 | Media | `85dce22` | `test_security.py::test_verify_password_returns_false_for_an_unparseable_hash` | SEC |
| B-14 | Token admin confrontato con `!=` invece che a tempo costante | Media | `85dce22` | coperto dai test di autenticazione admin esistenti (`test_admin.py::test_admin_rejects_wrong_token`); nessun marcatore B-14 nel codice | SEC |
| B-15 | `JWT_SECRET` o `XPRV_ENCRYPTION_KEY` vuoti non venivano rifiutati all'avvio | Media | `25f4a1c` Refuse to start half-configured, and keep failures machine-readable | `test_config.py` | SEC |
| B-16 | `GET /guida` rispondeva 500 in Docker perché `docs/` non era copiata nell'immagine | Media | `25f4a1c` (crash eliminato, 404 `guide_unavailable`); superato da `447bbba`, che serve un segnaposto statico `app/static/guida.html` | *manuale* (dipende dal layout dell'immagine); nessun marcatore B-16 nel codice | ARC, OPS |
| B-17 | La ricerca dell'uscita di resto nell'RBF poteva ridurre l'uscita del destinatario invece del resto (prelievo verso il proprio indirizzo) | Media | `d528c5b` | `test_withdrawals.py::test_withdrawal_to_own_address_is_rejected` | WDR, TX |
| B-18 | Sessione del database tenuta aperta durante le chiamate di rete del pagamento | Media | `daf66fd` | coperto dai test dello scheduler esistenti (`test_scheduler.py`) | RND, TX |
| B-19 | `tip_height` assegnato senza controllo di monotonia (reorg o cambio server lo facevano regredire) | Media | `7c4e998` | `test_electrum_listener.py::test_tip_never_moves_backwards` | ELC |
| B-20 | `PendingTransaction.replaced_by_txid` mai scritto: colonna morta mostrata in `/admin` | Bassa | `d528c5b` | `test_broadcast.py::test_bump_fee_retargets_every_stored_txid` | TX, DAT |
| B-21 | Variabile locale inutilizzata e accesso ad attributi di oggetti ORM staccati dalla sessione nel poller delle conferme | Bassa | `d528c5b` | coperto da `test_confirmation.py` | TX |
| B-22 | Importi mostrati con una divisione in virgola mobile: artefatti visibili agli utenti | Bassa | `dc4d576` | *manuale* (resa nel browser) | ARC |
| B-23 | Due `withLoading` concorrenti sullo stesso pulsante potevano lasciarlo bloccato sull'etichetta di caricamento | Bassa | `dc4d576` | *manuale* (interazione nel browser) | ARC |
| B-24 | Il gestore globale delle eccezioni rispondeva con un `detail` stringa invece che strutturato | Bassa | `25f4a1c` | `test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape` | API |
Note:
- **B-09** fu corretto diversamente dal piano: invece di rendere lo scheduler
l'unico a poter aprire un round, entrambi i chiamanti restano e il conflitto
sull'indice unico viene recuperato con un numero limitato di tentativi.
- **B-15** fu corretto con una validazione all'avvio (`validate_runtime_secrets`
chiamata dal lifespan) invece che con un validatore Pydantic, per non
rendere ogni import e l'intera suite di test dipendenti da un `.env`.
- **B-16** era stato dichiarato «parzialmente corretto per scelta»: la copia di
`docs/` nell'immagine fu scritta e poi ritirata su richiesta del
proprietario. Oggi `/guida` serve un segnaposto (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
---
## Audit del 2026-07-27 (B-25 … B-49)
Secondo audit completo: 25 difetti (4 critici, 6 alti, 7 medi, 8 bassi), con
139 test verdi al momento dell'audit. Corretti nel ramo `audit-2026-07-27`,
unito con `5cfe2d6`; ognuno ha il proprio commit. La suite passò da 139 a 253
test.
L'osservazione finale di quell'audit resta valida: il codice era rigoroso sui
guasti già capitati e silenzioso su quelli mai visti.
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-25 | Il pagamento non aveva la scrittura in due fasi (salvare prima, inviare poi) che avevano scommesse e prelievi | Critica | `f13f685` Persist the payout before broadcasting it | `test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `::test_trigger_payout_broadcast_failure_leaves_a_recoverable_row` | RND, TX |
| B-26 | Un errore transitorio al momento del pagamento, o un riavvio, lasciava il round in `paying_out` per sempre | Critica | `50a43ae` Retry a stuck payout automatically, and log every failure | `test_scheduler.py::test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure`, `::test_trigger_payout_logs_a_failure_when_not_connected` | RND |
| B-27 | Ogni RBF azzerava l'orologio di abbandono del riconciliatore, che quindi non scattava mai | Critica | `933760e` Decouple the RBF abandon clock from the bump clock | `test_reconcile.py::test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast`; `test_broadcast.py::test_should_bump_measures_from_last_broadcast_not_first` | TX |
| B-28 | Un server Electrum ostile (o un attacco man-in-the-middle) poteva scegliere da solo il vincitore del round | Critica | `0ce0562` Validate Electrum headers and corroborate the draw's block | `test_electrum_listener.py::test_apply_header_rejects_one_that_fails_its_own_pow_target`; `test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration`; `test_draw.py::test_header_meets_its_own_target_accepts_a_real_mined_header` | ELC, RND |
| B-29 | Una moneta assente da un solo `listunspent` veniva segnata come spesa all'esterno subito e irreversibilmente | Alta | `e8fdea0` Corroborate an external spend before marking a UTXO gone | `test_electrum_listener.py::test_refresh_user_does_not_mark_when_corroboration_fails`; `test_deposits.py::test_find_utxos_missing_from_skips_a_totally_empty_response` | DEP, ELC |
| B-30 | Nessuna riconciliazione dei depositi: una sottoscrizione persa significava depositi mai accreditati | Alta | `63df38d` Add a periodic deposit reconciler, and stop losing subscribe tasks | `test_deposit_reconcile.py`; `test_electrum_listener.py::test_address_for_new_user_retains_and_logs_a_failed_subscribe_task` | DEP |
| B-31 | Alla riconnessione la risottoscrizione di tutti gli utenti era seriale e bloccava l'altezza della catena, e con essa un'estrazione in corso | Alta | `12df041` Resubscribe concurrently and in the background on reconnect | `test_electrum_listener.py::test_subscribe_all_users_bounds_concurrency`, `::test_run_once_keeps_consuming_headers_while_resubscribing` | ELC |
| B-32 | `bump_fee` poteva ritentare per sempre sotto il minimo di commissione imposto da BIP125, senza tetto al fee rate | Alta | `17c557b` Meet BIP125's relay minimum on every RBF bump, and cap the fee rate | `test_broadcast.py::test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target`, `::test_bump_fee_refuses_once_at_the_max_fee_rate` | TX |
| B-33 | Nessuna protezione dai tentativi ripetuti sul login di un portafoglio custodiale | Alta | `16802ca` Throttle login and registration with exponential backoff | `test_auth.py::test_login_locks_out_after_repeated_failures`, `::test_login_failures_against_one_account_do_not_lock_out_another` | SEC |
| B-34 | Cambio password e reset admin non invalidavano le sessioni già emesse | Alta | `739fc9f` Invalidate existing sessions on password change/reset | `test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one`; `test_admin.py::test_admin_resets_user_password`; `test_security.py::test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed` | SEC |
| B-35 | Ogni orario dell'API era senza fuso, e il frontend lo interpretava come ora locale | Media | `bb8b712` Stamp UTC on naive API timestamps before serializing | `test_api_timeutil.py::test_naive_datetime_is_stamped_utc`; `test_users.py::test_me_created_at_is_utc_stamped` | API |
| B-36 | L'attesa del blocco per l'estrazione non aveva né timeout né visibilità | Media | `7fa26df` Make a stalled draw wait observable | `test_scheduler.py::test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold`; `test_rounds_route.py::test_draw_waiting_since_is_exposed_only_while_drawing` | RND, AUD |
| B-37 | Saldo mostrato e saldo spendibile divergevano, e l'errore «saldo insufficiente» non lo spiegava | Media | `0b44fe6` Distinguish a pending-only balance from a truly insufficient one | `test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance` | WDR, DEP |
| B-38 | Il tetto globale di 500 abbonati SSE era un modo gratuito per negare il canale in tempo reale a tutti | Media | `702b37b` Cap SSE subscribers per client IP instead of only globally | `test_round_events.py::test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap` | SEC, ARC |
| B-39 | SQLite senza WAL né `busy_timeout`, con più attività che scrivono in parallelo | Media | `97545ad` Run SQLite in WAL mode with a busy_timeout | `test_db_base.py` | ARC, DAT |
| B-40 | `bump_fee` teneva aperta una sessione del database durante N chiamate di rete, e calcolava gli importi da un valore in virgola mobile | Media | `08c566d` Restructure bump_fee into three phases, drop float fee math | `test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls` | TX |
| B-41 | Conferme e riconciliazione dipendevano da `blockchain.transaction.get` in modalità verbose, rifiutata da molti server, e l'abbandono si basava sul confronto testuale del messaggio d'errore | Media | `4124dc0` Check confirmation/existence via scripthash history, not verbose replies | `test_confirmation.py::test_one_unresolvable_candidate_does_not_block_the_others`; `test_reconcile.py`; `test_pending_address.py` | TX, ELC |
| B-42 | `/docs`, ReDoc e lo schema OpenAPI esposti in produzione, endpoint admin compresi | Bassa | `22e3cfb` Disable Swagger/ReDoc/OpenAPI JSON by default | `test_docs_disabled.py` | SEC |
| B-43 | Nessun header di sicurezza HTTP (CSP, HSTS, `X-Frame-Options`…) | Bassa | `d60da11` Add baseline HTTP security headers in Caddy | `test_caddyfile_security_headers.py` | SEC |
| B-44 | README e `docs/running-the-server.md` documentavano un `uvicorn --reload` diretto, contro la regola «solo Docker» | Bassa | `a574db0` Align README and running-the-server.md with the Docker-only policy | `test_docs_no_bare_uvicorn.py` | OPS |
| B-45 | Endpoint admin di elenco senza limiti validati né paginazione | Bassa | `6045c89` Bound admin list endpoint limits, add status filter to pending-transactions | `test_admin.py::test_admin_list_endpoints_reject_out_of_range_limit`, `::test_admin_pending_transactions_status_filter` | API |
| B-46 | `secrets.compare_digest` su una stringa con caratteri non ASCII sollevava `TypeError` (500 invece di 403) | Bassa | `31bc9a3` Compare admin token as UTF-8 bytes to avoid TypeError on non-ASCII input | `test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500` | SEC |
| B-47 | Colonne `String` senza lunghezza per testi lunghi (`raw_tx_hex`, `payload_json`) | Bassa | `6a90136` Widen raw_tx_hex and payload_json from String to Text | `test_models_text_columns.py` | DAT |
| B-48 | Nessun limite al numero di monete in ingresso: un indirizzo frammentato erodeva la propria scommessa o il proprio prelievo in commissioni | Bassa | `4c80c1c` Cap the number of inputs a transaction may spend | `test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`; `test_bets.py::test_place_bet_reports_a_too_fragmented_balance_distinctly` | BET, WDR |
| B-49 | I percorsi di annullamento di scommessa e prelievo non pubblicavano un aggiornamento SSE | Bassa | `e7f844b` Publish an SSE update from the bet/withdrawal rollback paths | `test_bets.py::test_failed_broadcast_publishes_an_sse_update`; `test_withdrawals.py::test_failed_broadcast_publishes_an_sse_update` | BET, WDR, ARC |
---
## Fuori audit (B-50, B-51)
Trovati il 2026-07-31 osservando il sistema in funzione, fra il secondo e il
terzo audit; non sono mai comparsi in un `BUGS.md` e la gravità non è stata
registrata. Il terzo audit riparte «dall'ultimo difetto corretto, B-51».
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-50 | Il banner di vincita appariva prima che `winner_amount_sats` fosse noto, mostrando «Hai vinto! +— PLM» | — | `9207bbc` Don't reveal the win banner before winner_amount_sats is known | *manuale* (solo `app/static/app.js`); nessun marcatore B-50 nel codice | RND, ARC |
| B-51 | Il saldo in attesa contava due volte il resto di una scommessa o di un prelievo già accreditato dal listener | — | `fe909be` Don't double-count a bet/withdrawal's own change in pending balance | `test_balance.py::test_pending_balance_does_not_double_count_change_already_credited` (senza marcatore B-51) | DEP |
---
## Audit del 2026-08-03 (B-52 … B-72)
Terzo audit completo (264 test verdi al momento dell'audit), aperto con
`99d7a1e` nel ramo `audit-2026-08-03`, poi confluito in `main`. Ogni difetto è
stato rimosso da `BUGS.md` nel suo stesso commit di correzione. Due voci
restano aperte.
L'audit ricorda anche un problema **non numerato**, perché già elencato fra i
limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-52 | Un round con più di ~50 partecipanti bloccava la piattaforma per sempre: il pagamento usava il limite di 50 monete in ingresso pensato per le transazioni utente | Critica | `025754c` Cap participants per round and give the payout its own input limit | `test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`; `test_payout_builder.py::test_payout_spends_more_utxos_than_a_user_transaction_may`; `test_scheduler.py::test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs` | RND, BET |
| B-53 | Una scommessa poteva finire nel montepremi ed essere comunque esclusa dall'estrazione | Critica | `64f6229` Close the window where a bet pays into a round it was left out of | `test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `::test_place_bet_rechecks_the_deadline_after_building_the_transaction`; `test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check` | BET, RND |
| B-54 | `X-Forwarded-For` accettato alla cieca: ogni controllo basato sull'IP era aggirabile | Alta | `907e32e` Make X-Forwarded-For trustworthy instead of attacker-controlled | `test_client_ip.py::test_client_ip_takes_the_last_hop_of_a_forwarded_chain`, `::test_client_ip_ignores_a_client_supplied_prefix`; `test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer` | SEC |
| B-55 | L'hashing Argon2 girava sull'event loop, bloccando l'intero processo a ogni login | Alta | `9c7befe` Hash and verify passwords off the event loop | `test_security.py::test_hash_password_async_keeps_the_event_loop_free`, `::test_verify_password_async_keeps_the_event_loop_free` | SEC |
| B-56 | Il dizionario dei contatori del limitatore di tentativi non veniva mai sfoltito | Alta | `ab65728` Bound the rate limiter's bucket dict | `test_rate_limit.py::test_the_dict_is_capped_even_within_one_sweep_interval`, `::test_spent_buckets_are_swept_on_the_next_failure` | SEC |
| B-57 | Username confrontati distinguendo maiuscole e minuscole, mentre la chiave del limitatore no | Alta | `5772135` Make usernames one case-insensitive namespace | `test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `::test_login_accepts_the_username_in_any_case`, `::test_the_database_itself_rejects_a_case_variant` | REG, SEC |
| B-58 | Il limite sulle registrazioni contava i successi come fallimenti ed era solo per IP | Alta | `0aac73e` Bound registrations with a per-IP quota instead of failure backoff | `test_auth.py::test_registration_is_quota_limited_per_ip`, `::test_failed_registrations_do_not_consume_the_quota` | SEC, REG |
| B-59 | L'accredito dei depositi non era corroborato dagli altri server, a differenza del rilevamento delle spese esterne | Alta | `6246b13` Corroborate deposit credits, not just external spends | `test_electrum_listener.py::test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports`, `::test_refresh_user_credits_once_the_others_corroborate` | DEP, ELC |
| B-60 | `POST /admin/bug-reports/{id}/status` non scriveva nulla nel registro di audit | Alta | `77e07e8` Audit-log bug report status changes | `test_bug_reports.py::test_status_change_is_audit_logged`, `::test_setting_the_status_it_already_has_logs_nothing` | AUD |
| B-61 | Le modifiche alla configurazione si applicavano retroattivamente al round in corso | Media | `37cc5ee` Snapshot a round's timing when it opens | `test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`; `test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing` | RND |
| B-62 | «Preleva tutto» produceva sempre una transazione senza resto, quindi impossibile da accelerare con RBF | Media | `8dd913e` Always keep a change output, so every tx stays fee-bumpable | `test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`; `test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output` | TX, WDR, BET |
| B-63 | Subito dopo la connessione, con `tip_height == 0`, un'estrazione poteva usare un blocco precedente alla chiusura | Media | `8a0ebec` Never let a draw be seeded by a block that predates the close | `test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`; `test_electrum_listener.py::test_run_once_publishes_the_client_only_once_the_tip_is_known` | RND, ELC |
| B-64 | `_apply_header` accettava un header alla stessa altezza senza controllo di concatenazione | Media | `c0314e2` Never swap the tip's hash sideways, and never split it from its height | `test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height`, `::test_apply_header_ignores_one_carrying_no_hex` | ELC |
| B-65 | `/rounds/current` contava i partecipanti non confermati, che estrazione e pagamento invece escludono | Media | `c4b2dc3` Advertise the jackpot that will actually be paid | `test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid` | RND, API |
| B-66 | Nulla impediva di aprire round senza `fee_address` configurato, rendendoli impagabili | Media | `23d5879` Refuse to open a round that could not pay its winner | `test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `::test_a_round_in_progress_survives_the_fee_address_being_cleared`; `test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address` | RND |
| B-67 | `/qr/{address}` non autenticato, sincrono e validato solo nella forma | Media | `5f6abe5` Validate and bound the QR endpoint | `test_qr.py` | API, SEC |
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC |
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS |
Note:
- `8f3cdcb` e `421fe72` sono commit di sola contabilità di `BUGS.md` (registrano
gli hash delle correzioni di B-53 e B-54), non correzioni.
- Il test di B-70 sulla documentazione legge la sezione «Scommessa e prelievo
insieme» di [scommesse-e-prelievi.md](scommesse-e-prelievi.md): quel titolo e
le frasi che il test cerca vanno mantenuti.
## Stato attuale
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato.
- **Aperti:** B-71, B-72 (entrambi di gravità bassa, entrambi operativi o di
documentazione).
- **Prossimo numero libero:** `B-73`.
+230
View File
@@ -0,0 +1,230 @@
# Transazioni e riconciliazione
Come ogni movimento di denaro — scommessa, prelievo, pagamento del round —
viene registrato, inviato, accelerato e infine confrontato con la catena.
L'obiettivo è che il sistema si rimetta in ordine da solo dopo un crash o un
problema di rete, senza interventi manuali sul database.
I requisiti usano il prefisso `TX`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
`pending_transactions` sono descritte in [modello-dati.md](modello-dati.md),
gli eventi di audit in [audit-log.md](audit-log.md), le attività in
background e le loro cadenze in [architettura.md](architettura.md).
## Il principio: prima si scrive, poi si invia
Nessuna transazione viene inviata alla rete senza che il database ne abbia già
traccia. Ogni transazione in uscita ha una riga `PendingTransaction` che
attraversa questi stati:
```mermaid
stateDiagram-v2
[*] --> building: transazione firmata,<br/>monete segnate come spese,<br/>salvato PRIMA dell'invio
building --> pending: la rete accetta l'invio
building --> [*]: scommessa rifiutata dalla rete<br/>(riga cancellata, tutto annullato)
building --> failed: prelievo rifiutato dalla rete<br/>(tutto annullato)
building --> pending: il riconciliatore la trova<br/>in rete (dopo 120s)
building --> failed: il riconciliatore non la<br/>trova in rete (dopo 120s)
pending --> pending: nessuna conferma<br/>entro il tempo limite → RBF
pending --> confirmed: 1 conferma
pending --> failed: il riconciliatore non la<br/>trova in rete (dopo 6h dal primo invio)
confirmed --> [*]
failed --> [*]
```
- **[TX-01]** Ogni transazione in uscita ha una riga `PendingTransaction` di
tipo `bet`, `withdrawal` o `payout`, con stato `building`, `pending`,
`confirmed` o `failed`. La riga (stato `building`, con la transazione
firmata completa) viene salvata e committata **prima** dell'invio, nella
stessa transazione del database in cui le monete spese vengono segnate come
tali e si registra l'oggetto collegato (partecipante, prelievo, dati di
pagamento del round).
*Codice:* `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`
- **[TX-02]** Il database non viene mai tenuto aperto durante una chiamata di
rete: la scrittura di `building` è committata, poi si invia, poi si aggiorna
(in una nuova sessione per il pagamento). Se il processo muore proprio in
mezzo, resta la prova di cosa stava succedendo, non monete spese in catena
senza alcun record.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`
- **[TX-03]** `broadcast_at` è l'orario in cui la riga è stata scritta, cioè
del **primo** invio, e non viene mai riscritto; `last_broadcast_at` è
quello dell'ultimo invio, ed è l'unico che l'RBF aggiorna.
*Codice:* `app/db/models.py:PendingTransaction`, `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast.py`
### Invio rifiutato
- **[TX-04]** Se l'invio di una scommessa o di un prelievo fallisce, tutto
torna com'era: monete liberate, saldo ripristinato, evento scritto nel
registro di audit (`bet_broadcast_failed`, `withdrawal_broadcast_failed`),
notifica SSE. Per una scommessa partecipante e riga vengono **cancellati**;
per un prelievo prelievo e riga restano, in stato `failed`. L'utente riceve
`broadcast_failed` con codice HTTP **502**, perché è stata la rete a
rifiutare, non la sua richiesta a essere sbagliata. Qualunque errore
dell'invio è trattato così, anche la scadenza del tempo di risposta.
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
- **[TX-05]** Se l'invio di un pagamento fallisce, la riga resta `building`
e si scrive `payout_failed` (`broadcast rejected`): è il riconciliatore a
decidere, interrogando la rete, se la transazione è partita o no
([TX-16], [TX-17]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_scheduler.py`
## Conferma
- **[TX-06]** `ConfirmationPoller` controlla ogni 10 secondi le transazioni
in stato `pending` (non quelle `building`), e salta il giro se non c'è
connessione. Per ciascuna legge la cronologia
(`blockchain.scripthash.get_history`) dell'indirizzo che la spende —
l'indirizzo dell'utente, o quello del montepremi per un pagamento — e la
considera confermata quando il suo txid corrente vi compare con altezza
maggiore di 0. La cronologia è letta una volta per indirizzo a ogni giro;
un indirizzo che non si riesce a leggere non blocca gli altri.
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` ·
*Test:* `tests/unit/test_confirmation.py`
- **[TX-07]** Alla prima conferma la riga passa a `confirmed` e, nella stessa
transazione del database, viene chiamato il gestore del suo tipo; poi si
invia una notifica SSE:
| Tipo | Cosa succede alla conferma |
|---|---|
| Scommessa | Il partecipante (se `building` o `broadcast`) diventa `confirmed`, con `confirmed_at`, ed entra nell'estrazione |
| Prelievo | Il prelievo (se `building` o `broadcast`) diventa `confirmed`, con `confirmed_at` |
| Pagamento | Il round (se `paying_out`) passa a `closed`, `closed_at` diventa l'orario della conferma e parte la pausa prima del successivo |
*Codice:* `app/tx/confirmation.py:poll_once`, `app/bets/confirmation.py:_on_bet_confirmed`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed`, `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
- **[TX-08]** I gestori ritrovano l'oggetto a cui la transazione si riferisce
tramite identificativi fissi (round e utente per la scommessa, prelievo,
round per il pagamento), **mai principalmente tramite il txid**, perché il
txid cambia a ogni RBF; il txid serve solo come ripiego per righe prive di
quei collegamenti.
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed`, `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
I gestori sono registrati all'avvio importando i moduli `confirmation` di
scommesse, round e prelievi (vedi [architettura.md](architettura.md)).
## RBF: accelerare una transazione lenta
Se una transazione resta senza conferma oltre `rbf_timeout_seconds` (900s
predefiniti), `RbfBumper` la ricostruisce con una commissione più alta e la
reinvia (Replace-By-Fee). Tutte le transazioni della piattaforma segnalano la
sostituibilità fin dal primo invio.
- **[TX-09]** `RbfBumper` controlla ogni 30 secondi le righe in stato
`pending` (salta il giro senza connessione) e accelera quelle il cui
`last_broadcast_at` è più vecchio di `rbf_timeout_seconds`: dopo ogni
accelerazione la transazione ha di nuovo l'intero tempo limite. Un errore
su una riga viene registrato nei log e non ferma le altre.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:should_bump` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-10]** La nuova commissione punta a 1 sat/vB in più della tariffa
registrata (`_FEE_RATE_INCREMENT`), senza superare 10.000 sat/vB
(`MAX_FEE_RATE_SAT_VB`); l'aumento assoluto non è mai inferiore alla
dimensione stimata della transazione per 1 sat/vB, il minimo richiesto da
BIP125 per una sostituzione. Una riga già a 10.000 sat/vB non viene più
accelerata. Gli importi delle monete spese sono letti dalle transazioni
precedenti come interi in satoshi.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-11]** **Paga chi riceve il resto**: la sostituzione spende le stesse
monete e riduce del solo aumento l'uscita di resto verso il mittente —
l'utente per scommesse e prelievi, il montepremi per i pagamenti. Le uscite
verso la controparte (montepremi, indirizzo esterno, vincitore, indirizzo
commissioni) non vengono mai toccate. Se non c'è un'uscita di resto verso il
mittente, o non supera l'aumento, l'accelerazione non è possibile: l'errore
viene registrato nei log, la transazione resta in attesa (il tentativo si
ripete a ogni giro) e prima o poi viene abbandonata dal riconciliatore, che
ne libera le monete.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-12]** Dopo l'invio della sostituzione la riga registra il nuovo txid
(`current_txid`), la nuova transazione (`raw_tx_hex`), il txid sostituito
(`replaced_by_txid`), la tariffa effettivamente pagata, il numero di
tentativi e `last_broadcast_at`. Il nuovo txid viene propagato ovunque sia
registrato: monete spese (`spent_txid`), partecipante (`bet_txid`),
prelievo (`txid`), round (`payout_txid`).
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_retarget_txid_references` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-13]** Se al momento dell'accelerazione la riga non esiste più o non è
più `pending` (ad esempio è appena stata confermata), non si fa nulla.
*Codice:* `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast.py`
Scommesse e prelievi nascono sempre con un'uscita di resto di almeno 294 sat
proprio perché l'RBF abbia qualcosa da ridurre
([scommesse-e-prelievi.md](scommesse-e-prelievi.md)). Il pagamento del round
invece ha un resto verso il montepremi solo quando le monete spese superano il
montepremi di almeno 294 sat: senza resto non è accelerabile.
## Riconciliazione
- **[TX-14]** `PendingTransactionReconciler` gira all'avvio e poi ogni 120
secondi (salta il giro senza connessione), e considera ogni transazione
ancora `building` o `pending` trascorso il suo periodo di tolleranza,
misurato da `broadcast_at` (il primo invio, che l'RBF non sposta): 120s per
`building`, 6 ore per `pending`, così l'RBF ha il tempo di fare i suoi
tentativi.
*Codice:* `app/tx/reconcile.py:PendingTransactionReconciler.run`, `app/tx/reconcile.py:_is_due` ·
*Test:* `tests/unit/test_reconcile.py`
- **[TX-15]** L'esistenza di una transazione si decide cercando il suo txid
corrente nella cronologia (`blockchain.scripthash.get_history`)
dell'indirizzo che la spende, in mempool o in un blocco. Un **errore di
rete** non porta a nulla: la riga resta com'è fino al giro successivo. Solo
un server che risponde con una cronologia priva del txid porta
all'abbandono, mai un server irraggiungibile.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_reconcile.py`
- **[TX-16]** **Presente in rete**: una riga `building` viene promossa a
`pending` (e partecipante o prelievo da `building` a `broadcast`), con un
evento `pending_tx_recovered`; da lì la conferma segue la via normale. Una
riga già `pending` presente in rete non viene toccata.
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* `tests/unit/test_reconcile.py`
- **[TX-17]** **Sconosciuta alla rete**: la riga passa a `failed` con motivo
`not found on chain`; le monete che risultano ancora impegnate da *questa*
transazione vengono liberate (non quelle già spese da altro); il
partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il
saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned`
(con il numero di monete liberate) e si invia una notifica SSE.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs` ·
*Test:* `tests/unit/test_reconcile.py`
È questo che rende finita l'attesa del round in `closing`: una scommessa che
non confermerà mai viene prima o poi abbandonata e rimossa.
## Pagamenti del round: riprovare senza pagare due volte
Il pagamento ha bisogno di più protezioni degli altri tipi, perché è
l'operazione che lo scheduler ritenta da solo. Le regole sono in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento):
ogni tentativo fallito scrive un evento `payout_failed` con il motivo, così
`/admin` mostra **perché** un round è fermo, e i tentativi sono limitati a
uno ogni 60 secondi usando proprio l'ultimo `payout_failed` come orologio
([RND-25](ciclo-di-vita-del-round.md#4--pagamento)); non si costruisce un
pagamento se ne esiste già uno `building` o `pending` per quel round, e le
monete del montepremi già impegnate da un pagamento non risolto sono escluse
dalla selezione ([RND-26](ciclo-di-vita-del-round.md#4--pagamento)). Senza
queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
- **[TX-18]** Un pagamento abbandonato non libera monete utente (quelle del
montepremi non sono registrate): azzera `payout_txid` del round, che resta
in `paying_out` senza transazione, e lo scheduler ricostruisce il pagamento
al tentativo successivo.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
*Test:* `tests/unit/test_scheduler.py`
## Dove guardare
Nel pannello `/admin` (vedi [guida-admin.md](guida-admin.md) e
[operativita.md](operativita.md)):
- **Transazioni pendenti** — tutto ciò che è `building` o `pending`, con
orari di invio e candidati all'RBF;
- **Audit log** — `payout_sent`, `payout_failed`, `pending_tx_recovered`,
`pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi;
- **Round** — stato di ogni round, vincitore e txid del pagamento.