Files
plm-lottery/docs/architettura.md
T
davideandClaude Sonnet 5.5 13cb66cc06 Require a real Electrum quorum and allow certificate pinning (B-84, ELC-01, ELC-03, ELC-14, ELC-19, ARC-20, OPS-12)
An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every
corroboration pass, and with fallbacks the majority was taken over whoever
answered, so one reachable fallback decided alone. TLS certificates were never
checked.

- _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES)
  and a strict majority of the answers; no other server configured, or fewer than
  2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule.
- config.validate_quorum_servers, called from the lifespan after parse_endpoints,
  refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true
  (which logs a warning at every start).
- Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and
  ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the
  DER certificate after the TLS handshake and a mismatch fails like an unreachable
  server. A pin on a plaintext entry is a startup error.
- .env.example now ships the four bootstrap servers and documents both new
  variables; docs/setup.md, spec, storico and BUGS.md updated.
- Tests changed because the spec changed: the two "no other servers configured ->
  True" corroboration tests now assert that outcome only under allow_no_quorum.

Note: a dev .env with fewer than 2 fallback servers will now refuse to start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:04:35 +02:00

995 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<br/>(e stdout, B-92)"]
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`, che sta in `.env` ed è obbligatorio: il compose lo legge
come `${SITE_ADDRESS:?}` e si rifiuta di partire se manca. `localhost`
produce un certificato autofirmato dalla CA interna di Caddy, un dominio 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`
*Stato:* da implementare (B-93) · *Oggi:* `SITE_ADDRESS` si passa sul
comando e non impostato vale `localhost`, quindi un `docker compose up`
senza variabile riporta un server di produzione su `localhost`.
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
`--workers`), in ascolto su `0.0.0.0:8123`, con `--proxy-headers` e
`--forwarded-allow-ips` limitato all'indirizzo di Caddy: la rete di compose
ha una sottorete fissa e Caddy un indirizzo IPv4 fisso in essa. Così il log
di accesso registra l'IP del client invece di quello di Caddy. Tutto lo
stato in memoria descritto in
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
questo.
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
proxy e il log di accesso registra l'IP di Caddy.
- **[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. Il processo gira come
utente non root, su un'immagine di base fissata per digest e con dipendenze
installate da un file di lock con gli hash
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
- **[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`. La chiave di cifratura della master key è un
file a parte, `./secrets/xprv_encryption.key` (permessi `0600`, fuori da
`./data/` così un backup dei dati non la contiene), montato in sola lettura
come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da
`XPRV_ENCRYPTION_KEY_FILE` ([ARC-30]). Le directory di `./data/` hanno
permessi `0700`, i file di chiave `0600`. Le immagini sono etichettate per
rilascio, così un rollback sa quale immagine rimettere
([operativita.md](operativita.md) (B-93)).
*Codice:* `docker-compose.yml`
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
- **[ARC-61]** **Salute del container.** Il container `app` ha un
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
(timeout 5s, 3 tentativi, `start_period` 60s). `/health/ready` risponde 200
solo se la connessione Electrum è attiva, l'ultimo header è recente e lo
scheduler ha battuto da poco; le soglie sono di
[api.md](api.md#servizio) (B-92). Lo scheduler aggiorna il battito a ogni
giro **e** a ogni iterazione dell'attesa del blocco di estrazione, perché
quell'attesa gira dentro il giro ([ARC-27]) e dura più blocchi. Docker non
riavvia un container `unhealthy`: lo stato serve al monitoraggio
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
resta affidato a `restart: unless-stopped` quando il processo esce.
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
*Test:* —
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
`/health`, che risponde sempre `ok` ([ARC-17]).
### 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=FULL` e `busy_timeout=5000` ms: in WAL
lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il
database occupato aspetta fino a 5 secondi invece di fallire subito, e
`FULL` fa sì che un commit riuscito sopravviva a una mancanza di corrente
(con `NORMAL` si potevano perdere gli ultimi commit, per esempio una
registrazione, riusandone poi l'indice di derivazione). Ogni transazione che
sposta denaro si apre con `BEGIN IMMEDIATE`, così le letture su cui decide
sono isolate e l'attesa del lock avviene all'inizio, non alla prima
scrittura; un `database is locked` dopo un invio è gestito esplicitamente
(la riga resta al riconciliatore). Con un dialetto diverso da SQLite le
pragma non vengono applicate.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
`app/db/base.py:begin_immediate` ·
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.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`, **e anche su stdout**, così
`docker compose logs` li mostra senza entrare nel volume. La configurazione
avviene all'import di `app/main.py`, prima di qualunque altro modulo
applicativo.
*Codice:* `app/logging_config.py:setup_logging`
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
nel file; su console arrivano solo quelli di uvicorn, per la sua
configurazione predefinita.
## 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`), del montepremi di ogni round (`2/<round_id>`, B-80) e dell'indirizzo di riserva (`1/0`), `derive_round_pool_key` che sceglie la chiave che controlla l'indirizzo salvato sul round, 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`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79) |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`; limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione. *Da implementare:* pin del certificato per endpoint (B-84) |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md). Il docstring della classe cita un metodo `add_address` che non esiste: l'indirizzo di un nuovo utente si aggiunge con `address_for_new_user` |
| `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. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
| `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: quorum minimo (validate_quorum_servers)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
U->>L: shutdown
L->>L: chiusura dei flussi SSE (B-93, da implementare)
L->>L: cancel() dei 6 task (e attesa della fine, B-93), chiusura del client Electrum
```
Il diagramma mostra il comportamento atteso; i passi marcati «da
implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
### 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: dice solo che il processo risponde (*liveness*). Se il
servizio è davvero in grado di lavorare lo dice `GET /health/ready`
([ARC-61], [api.md](api.md#servizio) (B-92)).
*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`…), con
`Cache-Control: no-cache` (il browser li riconvalida a ogni caricamento).
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`
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
con una pagina nuova.
### 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 la chiave di cifratura della master key è vuota (il file
indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o è vuoto), 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`
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
`XPRV_ENCRYPTION_KEY` non sia vuota.
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); 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-60]** **Controlli di avvio.** Dopo [ARC-19] e [ARC-20] e prima di
creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito
impedisce di servire:
1. **quorum minimo**: almeno 2 server di riserva, salvo
`ALLOW_NO_QUORUM=true` (solo sviluppo, scritto nel log come
avvertimento) — regola in
[connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-84);
2. **master key**: la chiave viene decifrata e l'indirizzo di riserva del
montepremi e un campione degli indirizzi utente salvati vengono
ricalcolati e confrontati con il database — regola in
[sicurezza.md](sicurezza.md#regole-di-avvio) (B-89);
3. **avvio in pausa**: con `START_PAUSED=true` la lotteria viene messa in
pausa prima che lo scheduler parta — procedura in
[operativita.md](operativita.md#ripristino) (B-90).
Il controllo dei **buchi negli indici di derivazione** dopo un ripristino
(i 20 indici successivi al più alto assegnato, che non devono avere
cronologia in catena) ha bisogno della rete, quindi non può stare prima
dei task: gira alla prima connessione del listener, e finché non si è
concluso senza trovare cronologia la registrazione è rifiutata
([operativita.md](operativita.md#ripristino), B-90).
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo
i segreti, gli endpoint e il quorum il lifespan crea subito i task; la
chiave si decifra alla prima registrazione o al primo pagamento.
- **[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, in
quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn
aspetta le connessioni aperte e Docker lo uccide), ciascun task riceve
`cancel()` e il lifespan **ne attende la fine**, poi il client Electrum, se
presente, viene chiuso. Il compose dà al container `stop_grace_period: 30s`
per completare questa sequenza prima del `SIGKILL`.
*Codice:* `app/main.py:lifespan` · `app/rounds/events.py:RoundEventBroadcaster` · `docker-compose.yml` ·
*Test:* `tests/unit/test_code_comments.py`
*Stato:* da implementare (B-93) · *Oggi:* i task ricevono `cancel()` senza
attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide
il container dopo i 10s predefiniti.
## 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). Tre cambiamenti di
struttura sono attesi dal piano, con le regole in quel documento:
- il listener conserva gli ultimi 10 header ricevuti; un salto di più
blocchi scarica e valida gli header intermedi, e un header che si
concatena a uno di quelli conservati ma non alla punta è una
riorganizzazione da seguire, non la fine della sessione (B-74, B-85);
- una sessione connessa la cui punta non avanza da 360s, mentre un altro
server ne riporta una più alta, viene chiusa e si passa al server
successivo (B-86);
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o
malformata fa fallire la sola richiesta, non la connessione (B-75).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-74, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena
e non confronta la propria punta con gli altri server.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla
riga del round (`draw_height`, `participants_hash`, posizioni dei
partecipanti), in modo idempotente. L'attesa **resta dentro il giro**:
finché dura, lo scheduler non fa altro, il che è corretto perché nessun
altro round può aprirsi finché questo non è `closed`. Un'eccezione durante
l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato
salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del
round in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
`drawing` per sempre
([LIM-01](limiti-noti.md#robustezza-del-ciclo-di-vita)).
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions` (e, dal piano, sulla tabella
di tutti i txid di ogni riga, B-77); 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][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
| `electrum_cert_pin` | `ELECTRUM_CERT_PIN` | `""` | SHA-256 del certificato DER del primario; vuoto = non fissato ([connessione-electrum.md](connessione-electrum.md) ELC-03) |
| `allow_no_quorum` | `ALLOW_NO_QUORUM` | `False` | Permette di partire con meno di 2 server di riserva; solo sviluppo ([ARC-60]) |
| `xprv_encryption_key_file` | `XPRV_ENCRYPTION_KEY_FILE` | `/run/secrets/xprv_encryption_key` | File con la chiave Fernet della master xprv (obbligatorio, [ARC-19]); sostituisce `XPRV_ENCRYPTION_KEY` |
| `start_paused` | `START_PAUSED` | `False` | Avvia con la lotteria in pausa, dopo un ripristino ([ARC-60]) |
| `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`
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile
`XPRV_ENCRYPTION_KEY`.
- **[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 e suo motivo, conferme dei
depositi `deposit_confirmations`, conferme del blocco di estrazione
`draw_confirmations`, tetto al montepremi `max_round_pool_sats`) 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`
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:*
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il
motivo della pausa non esistono.
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste per le transazioni della
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
codice (vedi [limiti-noti.md](limiti-noti.md)). Le conferme
dei depositi sono invece configurabili ([ARC-31]).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
transazione, depositi e coinbase compresi.
## 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`. Oggi il riconciliatore pubblica per
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
pubblicazione solo sui cambiamenti è in B-96;
- 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)
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
## 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, quindi se il canale SSE è
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
filtri dell'audit log (B-86, B-92).
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
e l'audit log le ultime righe senza filtri.
### `/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 oggi: nessun test contro una
connessione Electrum reale né contro guasti di rete realistici (ritardi,
cadute a metà richiesta, risposte enormi, catene divergenti); 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 migrazioni Alembic su dati esistenti (la catena è eseguita solo su un database vuoto, [ARC-62]; lo schema degli
altri test nasce da `Base.metadata.create_all`). 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 [limiti-noti.md](limiti-noti.md)).
La revisione del 2026-09-28 ha osservato che nessuna delle sue falle
critiche sarebbe stata trovata da questa suite, che simula tutto: da qui
[ARC-62], [ARC-63] e [ARC-64].
- **[ARC-62]** **Test delle migrazioni.** Un test esegue `alembic upgrade head`
su un database vuoto e verifica che esistano gli indici su espressione
`ix_rounds_single_active` e `ix_users_username_lower`, che l'autogenerate
non vede e che `batch_alter_table` su SQLite elimina ricostruendo la
tabella con un solo avviso. Ogni migrazione batch su `rounds` o `users` li
ricrea esplicitamente.
*Codice:* `migrations/env.py` · `migrations/versions/` ·
*Test:* `tests/unit/test_migrations.py`
- **[ARC-63]** **Test di parità delle traduzioni.** Un test legge
`TRANSLATIONS` da `app/static/i18n.js` e fallisce se una delle sette lingue
ha un insieme di chiavi diverso da quello di `en` ([ARC-51]).
*Codice:* `app/static/i18n.js` ·
*Test:* —
*Stato:* da implementare (senza B-nn: chiude
[LIM-21](limiti-noti.md#prodotto-e-interfacce)) · *Oggi:* la parità è
verificata solo a mano.
- **[ARC-64]** **Strato di test di guasto e di integrazione**, in
`tests/integration/`, separato dalla suite unitaria: server Electrum
simulati a livello di socket che rispondono in ritardo, chiudono la
connessione dopo aver ricevuto un invio, mandano risposte oltre 16 MiB,
header a difficoltà minima o catene che si biforcano; più il database SQLite
su file con scrittori concorrenti. Non sostituisce la prova con denaro vero
e guasti provocati di
[operativita.md](operativita.md#prova-su-rete-reale), che resta obbligatoria
prima dell'apertura al pubblico.
*Codice:* `tests/integration/__init__.py` ·
*Test:* —
*Stato:* da implementare (fase 7 del
[piano](piano-correzioni.md#fase-7--prova-su-rete-reale)) · *Oggi:*
`tests/integration/` contiene solo `__init__.py`.
## 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
[limiti-noti.md](limiti-noti.md).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
`app/tx/locks.py:UserLocks` · `app/auth/rate_limit.py:AuthRateLimiters` ·
`app/main.py:lifespan`