# 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
(e stdout, B-92)"] 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`, 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/`, 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`; l'uscita di riserva per l'RBF sul pagamento (`payout_bump_reserve`, 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. Seme commit-reveal e impronta dei partecipanti | | `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) 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
(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=]` 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-90), per il controllo 3 · *Oggi:* il lifespan non ferma la registrazione né mette in pausa dopo un ripristino. - **[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; - 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/listener.py:ElectrumListener._check_not_stalled` · `app/electrum/client.py:ElectrumClient.connect` · *Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py` - **[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_draw_block` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py` - **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e **`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s) lavorano sulla tabella `pending_transactions` (e sulla tabella di tutti i txid di ogni riga, `pending_transaction_txids`); 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=]` 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` - **[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` ## 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`. 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-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.` 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 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`