# 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`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79) |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`; limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione. *Da implementare:* pin del certificato per endpoint (B-84) |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md). Il docstring della classe cita un metodo `add_address` che non esiste: l'indirizzo di un nuovo utente si aggiunge con `address_for_new_user` |
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
### Dominio
| File | Responsabilità |
|---|---|
| `app/deposits/service.py` | Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino |
| `app/deposits/reconcile.py` | `DepositReconciler`: ricontrollo periodico di tutti gli indirizzi |
| `app/bets/service.py` | `place_bet`, `BetError`, rilascio di una scommessa fallita |
| `app/bets/confirmation.py` | Gestore di conferma per il tipo `bet` |
| `app/withdrawals/service.py` | `request_withdrawal`, `WithdrawalError`, rilascio di un prelievo fallito |
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73) |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
Flussi descritti in [scommesse-e-prelievi.md](scommesse-e-prelievi.md),
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
[registrazione-e-depositi.md](registrazione-e-depositi.md).
### Transazioni
| File | Responsabilità |
|---|---|
| `app/tx/confirmation.py` | `ConfirmationPoller`, `poll_once`, registro dei gestori (`register_handler`) |
| `app/tx/broadcast.py` | `RbfBumper`, `should_bump`, `bump_fee` |
| `app/tx/reconcile.py` | `PendingTransactionReconciler`, `reconcile_once`: promozione o abbandono delle righe `building`/`pending` |
| `app/tx/pending_address.py` | `own_address_for`: indirizzo proprietario degli input di una transazione (utente o pool) |
| `app/tx/locks.py` | `UserLocks`: lock asincrono per utente |
Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
### Frontend statici
| File | Responsabilità |
|---|---|
| `app/static/index.html` | Pagina `/` per i giocatori |
| `app/static/app.js` | Logica di `/`: autenticazione, pannelli, polling, SSE, rivelazione del risultato |
| `app/static/style.css` | Stile di `/` (e di `/report-bug`) |
| `app/static/i18n.js` | Tabella `TRANSLATIONS` in 7 lingue, `t()`, `tOrNull()`, `setLanguage()`, `applyStaticTranslations()` |
| `app/static/report-bug.html` | Pagina `/report-bug`, con il proprio script inline |
| `app/static/admin.html` | Pannello `/admin` |
| `app/static/admin.js` | Logica del pannello: token, sezioni, polling dello stato catena, SSE |
| `app/static/admin.css` | Stile del pannello |
| `app/static/guida.html` | Pagina `/guida`, per ora un segnaposto |
### Dipendenze tra pacchetti
Il grafo riporta gli import tra i pacchetti di `app/` (a livello di pacchetto,
non di singolo modulo). `db` e `config` sono importati quasi ovunque e sono
omessi dalle frecce per leggibilità.
```mermaid
flowchart TD
main["main"] --> routes["api.routes"]
main --> auth
main --> tx
main --> rounds
main --> electrum
main --> deposits
main --> bets
main --> withdrawals
routes --> auth
routes --> bets
routes --> withdrawals
routes --> rounds
routes --> wallet
routes --> audit
routes --> apiutil["api (errors, client_ip, timeutil)"]
auth --> apiutil
auth --> wallet
bets --> apiutil
bets --> rounds
bets --> wallet
bets --> audit
bets -- "register_handler" --> tx
withdrawals --> apiutil
withdrawals --> rounds
withdrawals --> wallet
withdrawals --> audit
withdrawals -- "register_handler" --> tx
rounds --> electrum
rounds --> wallet
rounds --> audit
rounds -- "register_handler" --> tx
electrum --> deposits
electrum -- "draw (header math), events" --> rounds
deposits --> electrum
deposits --> wallet
deposits --> audit
deposits -- "events" --> rounds
tx --> wallet
tx --> electrum
tx --> audit
tx -- "events, config" --> rounds
```
- **[ARC-10]** Il poller delle conferme (`app/tx/confirmation.py`) non importa
i moduli di dominio: sono `app/bets/confirmation.py`,
`app/rounds/confirmation.py` e `app/withdrawals/confirmation.py` a
registrarsi con `register_handler(kind, handler)` al momento dell'import,
per i tipi `bet`, `payout` e `withdrawal`.
*Codice:* `app/tx/confirmation.py:register_handler` ·
`app/bets/confirmation.py` · `app/rounds/confirmation.py` ·
`app/withdrawals/confirmation.py` ·
*Test:* `tests/unit/test_confirmation.py`
- **[ARC-11]** Gli errori di dominio destinati all'utente sono sottoclassi di
`ApiError` (`BetError`, `WithdrawalError`) sollevate direttamente dai
servizi, con il codice che il frontend traduce; le route li convertono in
`HTTPException` scegliendo lo stato HTTP. Per questo `bets` e
`withdrawals` dipendono da `app/api/errors.py`.
*Codice:* `app/api/errors.py:ApiError` · `app/bets/service.py:BetError` ·
`app/withdrawals/service.py:WithdrawalError`
## Avvio del processo
`app/main.py` fa due cose distinte: **all'import** costruisce l'applicazione
FastAPI; **all'avvio del server** (il `lifespan`) prepara lo stato condiviso
e lancia le attività in background.
```mermaid
sequenceDiagram
participant U as uvicorn
participant M as app/main.py (import)
participant L as lifespan
U->>M: import app.main
M->>M: setup_logging()
M->>M: import dei gestori di conferma (bet, payout, withdrawal)
M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
U->>L: startup
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: quorum minimo (validate_quorum_servers)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste
(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-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo
i segreti, gli endpoint e il quorum il lifespan crea subito i task; la
chiave si decifra alla prima registrazione o al primo pagamento.
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
alla prima richiesta di registrazione o login che ne ha bisogno.
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento, in
quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn
aspetta le connessioni aperte e Docker lo uccide), ciascun task riceve
`cancel()` e il lifespan **ne attende la fine**, poi il client Electrum, se
presente, viene chiuso. Il compose dà al container `stop_grace_period: 30s`
per completare questa sequenza prima del `SIGKILL`.
*Codice:* `app/main.py:lifespan` · `app/rounds/events.py:RoundEventBroadcaster` · `docker-compose.yml` ·
*Test:* `tests/unit/test_code_comments.py`
*Stato:* da implementare (B-93) · *Oggi:* i task ricevono `cancel()` senza
attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide
il container dopo i 10s predefiniti.
## Attività in background
| Task | Costruito con | Cadenza | Ruolo |
|---|---|---|---|
| `ElectrumListener` | `_make_electrum_client`, `AsyncSessionLocal`, endpoint | ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi |
| `RoundScheduler` | `AsyncSessionLocal`, `listener` | 5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti |
| `ConfirmationPoller` | `AsyncSessionLocal`, `lambda: listener.client` | 10s | `pending` → `confirmed` e gestore per tipo |
| `RbfBumper` | `AsyncSessionLocal`, `lambda: listener.client` | 30s | Aumenta la commissione di ciò che è `pending` oltre `rbf_timeout_seconds` |
| `PendingTransactionReconciler` | `AsyncSessionLocal`, `lambda: listener.client` | subito, poi 120s | Promuove o abbandona le righe `building`/`pending` |
| `DepositReconciler` | `AsyncSessionLocal`, `listener` | 300s, attende prima del primo giro | `refresh_user` su ogni indirizzo |
- **[ARC-23]** **Accesso al client Electrum.** Nessun task tiene un
riferimento fisso al client: `ConfirmationPoller`, `RbfBumper` e
`PendingTransactionReconciler` ricevono `lambda: listener.client` e lo
rileggono a ogni giro; `RoundScheduler` e `DepositReconciler` ricevono il
listener intero e leggono `listener.client` (il secondo anche per chiamare
`refresh_user`). Così una riconnessione sostituisce il client sotto tutti.
Un client non nullo implica che l'altezza della catena sia già nota (vedi
[connessione-electrum.md](connessione-electrum.md)).
*Codice:* `app/main.py:lifespan`
- **[ARC-24]** **Client assente.** Quando `listener.client` è `None` ogni task
salta il giro e riprova alla cadenza successiva; `DepositReconciler`
interrompe anche una scansione in corso. Le route che devono trasmettere
(`POST /bets`, `POST /withdrawals`) rispondono invece `503`
`network_unavailable`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/tx/confirmation.py:ConfirmationPoller.run` ·
`app/tx/broadcast.py:RbfBumper.run` ·
`app/tx/reconcile.py:PendingTransactionReconciler.run` ·
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
- **[ARC-25]** **Errori.** Ogni ciclo intercetta qualunque `Exception` di un
giro, la scrive nel log con lo stack e riprende alla cadenza successiva;
solo `CancelledError` (lo spegnimento) esce dal ciclo. All'interno di un
giro, poller, bumper, riconciliatore e `DepositReconciler` isolano anche il
singolo elemento: una transazione o un utente che fallisce non ferma gli
altri. Non esiste un supervisore: un task che terminasse non verrebbe
riavviato (l'unico caso previsto è il listener senza alcun endpoint, che
scrive un errore ed esce; `parse_endpoints` include però sempre il server
primario).
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run` ·
`app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:RbfBumper._tick` ·
`app/tx/reconcile.py:reconcile_once` ·
`app/deposits/reconcile.py:DepositReconciler.run` ·
`app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_reconcile.py` ·
`tests/unit/test_deposit_reconcile.py`
- **[ARC-26]** **`ElectrumListener`.** Dopo ogni sessione terminata passa
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
scatta solo quando tutti i server hanno fallito nello stesso giro.
Rotazione, keepalive, validazione e corroborazione sono in
[connessione-electrum.md](connessione-electrum.md). Tre cambiamenti di
struttura sono attesi dal piano, con le regole in quel documento:
- il listener conserva gli ultimi 10 header ricevuti; un salto di più
blocchi scarica e valida gli header intermedi, e un header che si
concatena a uno di quelli conservati ma non alla punta è una
riorganizzazione da seguire, non la fine della sessione (B-74, B-85);
- una sessione connessa la cui punta non avanza da 360s, mentre un altro
server ne riporta una più alta, viene chiusa e si passa al server
successivo (B-86);
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o
malformata fa fallire la sola richiesta, non la connessione (B-75).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-85, B-86) · *Oggi:* una riorganizzazione
è seguita ma non registrata né segnalata con una pausa, un header
concorrente alla stessa altezza è ignorato, e il listener non confronta la
propria punta con gli altri server.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla
riga del round (`draw_height`, `participants_hash`, posizioni dei
partecipanti), in modo idempotente. L'attesa **resta dentro il giro**:
finché dura, lo scheduler non fa altro, il che è corretto perché nessun
altro round può aprirsi finché questo non è `closed`. Un'eccezione durante
l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato
salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del
round in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
`drawing` per sempre
([LIM-01](limiti-noti.md#robustezza-del-ciclo-di-vita)).
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions` (e 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`
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:*
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il
motivo della pausa non esistono.
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste per le transazioni della
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
codice (vedi [limiti-noti.md](limiti-noti.md)). Le conferme
dei depositi sono invece configurabili ([ARC-31]).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
transazione, depositi e coinbase compresi.
## Aggiornamenti in tempo reale (SSE)
Le pagine si aggiornano da sole in due modi sovrapposti: il polling
periodico e, in più, un canale **Server-Sent Events** che le avvisa appena
qualcosa cambia.
```mermaid
flowchart LR
subgraph Processo
P1["round aperto / cambio di stato"] --> BR
P2["scommessa o prelievo inviato / annullato"] --> BR
P3["transazione confermata / abbandonata"] --> BR
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
P5["nuovo header"] --> BR
BR["broadcaster.publish()"] --> Q1["coda client 1
(maxsize 1)"]
BR --> Q2["coda client N"]
end
Q1 -- "event: update" --> S1["EventSource /"]
Q2 -- "event: update" --> S2["EventSource /admin"]
S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"]
```
- **[ARC-33]** Il canale è pub/sub **in-process**: `broadcaster` è un unico
`RoundEventBroadcaster` per processo, con una `asyncio.Queue(maxsize=1)`
per client. `publish()` mette una notifica in ogni coda non piena; se la
coda è già piena la nuova notifica viene scartata, perché quella in attesa
basta già a far ricaricare tutto (notifiche ridondanti si fondono).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-34]** Il messaggio non ha contenuto e lo stream non ha
autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni
dato, compresa la personalizzazione (come `user_played`), arriva dagli
endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato
di ogni evento, anche di quelli che non lo riguardano.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-35]** Limiti di sottoscrizione: al massimo `MAX_SUBSCRIBERS` (500)
stream in tutto, oltre i quali `GET /rounds/stream` risponde `503` e il
browser ricade sul polling; e al massimo `MAX_SUBSCRIBERS_PER_IP` (5) per
IP client, oltre i quali la connessione **più vecchia** di quello stesso IP
viene chiusa (le si invia il segnale `EVICTED`) invece di rifiutare la
nuova. L'IP è quello di `client_ip` (vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` ·
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-36]** Lo stream invia `event: update` con `data: {}` per ogni
notifica. Da fermo controlla `request.is_disconnected()` ogni 5 secondi e
invia un commento `: keep-alive` ogni 20 secondi (4 controlli), così né un
client sparito senza chiudere né il timeout di inattività di un proxy lo
lasciano appeso. La risposta porta `Cache-Control: no-store` e
`X-Accel-Buffering: no`. Alla chiusura il client viene sempre rimosso.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-37]** `publish()` viene chiamato in questi punti, e ogni nuovo
cambiamento di stato visibile deve aggiungersi a questo elenco:
- apertura di un round — `app/rounds/service.py:open_new_round_if_needed`;
- ogni transizione di stato del round (`closing`, `closed` senza
partecipanti, `drawing`, `paying_out`) —
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`;
- scommessa inviata e scommessa annullata —
`app/bets/service.py:place_bet`, `app/bets/service.py:_release_failed_bet`;
- prelievo inviato e prelievo annullato —
`app/withdrawals/service.py:request_withdrawal`,
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
pubblicazione solo sui cambiamenti è in B-96;
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
`app/deposits/service.py:reinstate_reappeared_utxos`;
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
## Lock per utente
- **[ARC-38]** `UserLocks` tiene un `asyncio.Lock` per `user_id` (creato alla
prima richiesta di quell'utente e mai rimosso). `POST /bets` e
`POST /withdrawals` eseguono l'intera costruzione e trasmissione dentro
`app.state.user_locks.acquire(user.id)`: per lo stesso utente una scommessa
e un prelievo non sono mai in costruzione insieme, perché spendono dallo
stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi;
cosa implica è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md). Il
controllo "client connesso" avviene prima di acquisire il lock.
*Codice:* `app/tx/locks.py:UserLocks.acquire` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal`
## Frontend
- **[ARC-39]** Le interfacce sono pagine statiche in `app/static/`, servite
direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano,
senza passo di build, framework né bundler. Parlano tutte con la stessa API
JSON; l'unica separazione tra utente e amministratore è `require_admin` sul
lato server.
*Codice:* `app/main.py` · `app/static/`
### `/` — giocatori
- **[ARC-40]** **Polling.** Da non autenticati, la pagina interroga
`GET /rounds/current` ogni 15s solo per la barra di stato della catena. Da
autenticati: `refreshRound` ogni 15s, ogni 3s mentre il round è in
`closing`/`drawing`/`paying_out`; `checkLastRoundResult`
(`GET /users/me/last-round-result`) ogni 20s; il conto alla rovescia
locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano
subito (i timer delle schede in background vengono rallentati dal
browser).
*Codice:* `app/static/app.js:startChainOnlyPolling` ·
`app/static/app.js:scheduleNextRoundPoll` ·
`app/static/app.js:showDashboard`
- **[ARC-41]** **Richieste.** Ogni chiamata API di `/` ha un timeout di 15s;
dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione
persa". Una risposta `401` con codice `session_expired` riporta al login.
*Codice:* `app/static/app.js:call` · `app/static/app.js:noteFetchOutcome`
- **[ARC-42]** **SSE.** La pagina apre un solo `EventSource('/rounds/stream')`
e reagisce sia agli eventi `update` sia a `open` (che scatta alla prima
connessione e a ogni riconnessione automatica): da autenticati ricarica
round, profilo e ultimo risultato, altrimenti solo lo stato della catena.
Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina
funziona come prima, solo senza l'avviso immediato.
*Codice:* `app/static/app.js:connectRoundEvents` ·
`app/static/app.js:onRoundServerEvent`
- **[ARC-43]** **Chiavi di `localStorage`** usate da `/`:
| Chiave | Contenuto |
|---|---|
| `plm_token` | JWT della sessione |
| `plm_username` | Nome utente mostrato |
| `plm_address` | Indirizzo di deposito |
| `plm_persisted_result` | Risultato del round rivelato (`round_id`, `won`, `amount_sats`), per ridisegnarlo dopo un ricaricamento |
| `plm_lang` | Lingua scelta (condivisa con `/report-bug`) |
Il logout svuota `localStorage` tranne `plm_lang`. La rimozione di
`plm_token` in una scheda riporta al login anche le altre schede aperte
(evento `storage`). La logica della rivelazione del risultato è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/static/app.js:logout` · `app/static/app.js:persistResult` ·
`app/static/i18n.js:setLanguage`
### `/admin` — operatore
- **[ARC-44]** Il pannello chiede il token operatore, lo verifica con
`GET /admin/config` e lo conserva in `sessionStorage` (`plm_admin_token`,
quindi si perde chiudendo la scheda); ogni chiamata lo invia
nell'intestazione `X-Admin-Token`. Non è collegato da `/` né lo collega.
*Codice:* `app/static/admin.js:callAdmin`
- **[ARC-45]** Il pannello interroga `GET /rounds/current` ogni 15s per la
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
aperte; non hanno un polling periodico proprio, quindi se il canale SSE è
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
filtri dell'audit log (B-86, B-92).
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
e l'audit log le ultime righe senza filtri.
### `/report-bug` e `/guida`
- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza
login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e
per mostrare i propri report (`GET /bug-reports/mine`). Non usa SSE né
polling. Il flusso di triage è descritto in [api.md](api.md) e in
[guida-admin.md](guida-admin.md).
*Codice:* `app/static/report-bug.html`
- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida
completa sarà pubblicata qui a breve").
*Codice:* `app/static/guida.html`
## Internazionalizzazione
Riguarda `/` e `/report-bug`. `/admin` e `/guida` sono volutamente solo in
italiano.
- **[ARC-48]** Le lingue supportate sono sette: `en`, `it`, `es`, `fr`, `de`,
`ru`, `zh`. Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in
`app/static/i18n.js`, caricato prima dello script della pagina; non ci sono
file separati né richieste di rete.
*Codice:* `app/static/i18n.js`
- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di
`localStorage.plm_lang` se supportato, le prime due lettere di
`navigator.language` se supportate, altrimenti `en`. `setLanguage` salva la
scelta in `plm_lang`, ritraduce la pagina e chiama `onLanguageChange` se la
pagina la definisce. Su `/` il selettore sta nella barra di stato della
catena, non nella barra di navigazione, perché quest'ultima è nascosta fino
al login.
*Codice:* `app/static/i18n.js:detectDefaultLang` ·
`app/static/i18n.js:setLanguage` · `app/static/index.html`
- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n`
(testo), `data-i18n-html`, `data-i18n-placeholder`, `data-i18n-title`,
`data-i18n-aria-label`, `data-i18n-alt`, applicati da
`applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che
nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da
`onLanguageChange`. Un elemento usa **uno solo** dei due meccanismi, mai
entrambi (per esempio `#bet-btn` non ha `data-i18n`, perché la sua
etichetta contiene il costo configurabile).
*Codice:* `app/static/i18n.js:applyStaticTranslations` ·
`app/static/app.js:onLanguageChange` ·
`app/static/app.js:renderBetButton`
- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi**.
`t()` cerca la chiave nella lingua corrente, poi in `en`, e se manca anche
lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null`,
così il chiamante può usare un testo di riserva. I parametri si
sostituiscono nei segnaposto `{nome}`.
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull`
- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail`
strutturato `{code, message, params}` (vedi [api.md](api.md)); la
traduzione spetta al client. Per un `detail` oggetto il frontend mostra
`error.` 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`