# 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
(/, /admin, /report-bug)"] -- "HTTPS 443
(80 → redirect)" --> C["Container caddy
reverse proxy + TLS"] C -- "HTTP app:8123
(rete interna compose)" --> A["Container app
1 processo uvicorn
FastAPI + 6 task asyncio"] A -- "aiosqlite" --> D[("./data/db/plm_lottery.db
SQLite, WAL")] A -- "legge" --> K["./data/keys/master.xprv.enc"] A -- "scrive" --> L["./data/logs/app.log"] A -- "Electrum (TCP/SSL)
una connessione attiva" --> E["Server Electrum PLM
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
(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.` 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.` 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`