2026-09-28 12:16:01 +02:00
# Architettura
Come è fatto il software dall'interno: quali processi girano, come è
organizzato il codice, cosa succede all'avvio, quali attività lavorano in
background, dove sta la configurazione, come le pagine web restano
aggiornate e come è strutturata la suite di test.
Questo documento non descrive i flussi di dominio (scommesse, round,
depositi…): per quelli rimanda ai documenti che li possiedono, elencati in
[come-funziona.md ](come-funziona.md ). Le tabelle del database sono in
[modello-dati.md ](modello-dati.md ), gli endpoint in [api.md ](api.md ), le
difese di sicurezza in [sicurezza.md ](sicurezza.md ).
## Panoramica del deployment
```mermaid
flowchart LR
B["Browser<br/>(/, /admin, /report-bug)"] -- "HTTPS 443<br/>(80 → redirect)" --> C["Container caddy<br/>reverse proxy + TLS"]
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
2026-09-28 15:51:13 +02:00
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
2026-09-28 12:16:01 +02:00
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
- **[ARC-01]** Il server gira sempre in Docker, con lo stesso
`docker-compose.yml` in sviluppo e in produzione: due container, `app`
(questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo
2026-09-28 15:51:13 +02:00
`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.
2026-09-28 12:16:01 +02:00
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
2026-09-28 15:51:13 +02:00
*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` .
2026-09-28 12:16:01 +02:00
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
2026-09-28 15:51:13 +02:00
`--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.
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
`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
2026-09-29 09:17:52 +02:00
([sicurezza.md ](sicurezza.md#sec-51 ) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
2026-09-28 15:51:13 +02:00
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
2026-09-28 12:16:01 +02:00
*Codice:* `Dockerfile` (`CMD` ) · `migrations/env.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=` .
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
`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)).
2026-09-28 12:16:01 +02:00
*Codice:* `docker-compose.yml`
2026-09-28 15:51:13 +02:00
*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]).
2026-09-28 12:16:01 +02:00
### 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
2026-09-28 15:51:13 +02:00
`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.
2026-09-28 12:16:01 +02:00
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
2026-09-29 16:26:01 +02:00
`app/db/base.py:begin_immediate` ·
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
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.
2026-09-28 12:16:01 +02:00
*Codice:* `app/logging_config.py:setup_logging`
2026-09-28 15:51:13 +02:00
*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.
2026-09-28 12:16:01 +02:00
## 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 |
2026-09-28 15:51:13 +02:00
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i` ) e del pool (`1/0` ), export WIF; la chiave d'account decifrata resta in cache nel processo. *Da implementare:* indirizzo del montepremi per round (`2/<round_id>` ), con `1/0` come indirizzo di riserva (B-80) |
2026-09-28 12:16:01 +02:00
| `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 ) |
2026-09-28 15:51:13 +02:00
| `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), UTXO sotto `DUST_LIMIT_SATS` esclusi dalla selezione (B-75) |
2026-09-28 12:16:01 +02:00
### Rete Electrum
| File | Responsabilità |
|---|---|
2026-09-28 15:51:13 +02:00
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint` , `parse_endpoints` . *Da implementare:* limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione (B-75); 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` |
2026-09-28 12:16:01 +02:00
| `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 |
2026-09-28 15:51:13 +02:00
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
2026-09-28 12:16:01 +02:00
| `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(...)
2026-09-28 15:51:13 +02:00
L->>L: quorum minimo (B-84, da implementare)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
2026-09-28 12:16:01 +02:00
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task × 6
2026-09-28 15:51:13 +02:00
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
2026-09-28 12:16:01 +02:00
U->>L: shutdown
2026-09-28 15:51:13 +02:00
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
2026-09-28 12:16:01 +02:00
```
2026-09-28 15:51:13 +02:00
Il diagramma mostra il comportamento atteso; i passi marcati «da
implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
2026-09-28 12:16:01 +02:00
### 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
2026-09-28 15:51:13 +02:00
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)).
2026-09-28 12:16:01 +02:00
*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
2026-09-28 15:51:13 +02:00
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` .
2026-09-28 12:16:01 +02:00
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
`app/main.py:user_guide` · `app/main.py:report_bug_page`
2026-09-28 15:51:13 +02:00
*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.
2026-09-28 12:16:01 +02:00
### Nel lifespan
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
l'avvio (solleva `ConfigError` ) se `JWT_SECRET` è più corto di 32
2026-09-28 15:51:13 +02:00
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.
2026-09-28 12:16:01 +02:00
*Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
`XPRV_ENCRYPTION_KEY` non sia vuota.
2026-09-28 12:16:01 +02:00
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST` ,
2026-09-28 15:51:13 +02:00
`ELECTRUM_PORT` , `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
2026-09-28 12:16:01 +02:00
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`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-84) · *Oggi:* la sintassi è `host:porta[:notls]` ,
senza pin del certificato.
- **[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` ·
*Test:* —
*Stato:* da implementare (B-84, B-89, B-90) · *Oggi:* dopo i segreti e gli
endpoint il lifespan crea subito i task; la chiave si decifra alla prima
registrazione o al primo pagamento.
2026-09-28 12:16:01 +02:00
- **[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` ,
2026-09-28 15:51:13 +02:00
`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.
2026-09-28 12:16:01 +02:00
## 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
2026-09-28 15:51:13 +02:00
[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).
2026-09-28 12:16:01 +02:00
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
2026-09-28 15:51:13 +02:00
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
2026-09-28 12:16:01 +02:00
*Test:* `tests/unit/test_electrum_listener.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-74, B-75, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena,
non confronta la propria punta con gli altri server, e il client usa il
limite predefinito di 64 KiB di `asyncio.open_connection` , oltre il quale
cade l'intera connessione.
2026-09-28 12:16:01 +02:00
- **[ARC-27]** ** `RoundScheduler` .** A ogni giro (5s) chiama
2026-09-28 15:51:13 +02:00
`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 ).
2026-09-28 12:16:01 +02:00
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
2026-09-28 15:51:13 +02:00
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
2026-09-28 12:16:01 +02:00
*Test:* `tests/unit/test_scheduler.py`
2026-09-28 15:51:13 +02:00
*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 ](decisioni-e-limiti.md#robustezza-del-ciclo-di-vita )).
2026-09-28 12:16:01 +02:00
- **[ARC-28]** ** `ConfirmationPoller` ** (10s), ** `RbfBumper` ** (30s) e
** `PendingTransactionReconciler` ** (al primo giro subito, poi ogni 120s)
2026-09-28 15:51:13 +02:00
lavorano sulla tabella `pending_transactions` (e, dal piano, sulla tabella
di tutti i txid di ogni riga, B-77); il riconciliatore parte subito perché
un crash durante un invio lascia proprio una riga `building` da risolvere
al riavvio. Regole in
2026-09-28 12:16:01 +02:00
[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 |
2026-09-28 15:51:13 +02:00
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
2026-09-29 09:17:52 +02:00
| `electrum_cert_pin` | `ELECTRUM_CERT_PIN` | `""` | SHA-256 del certificato DER del primario; vuoto = non fissato ([connessione-electrum.md ](connessione-electrum.md ) ELC-03) |
2026-09-28 15:51:13 +02:00
| `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]) |
2026-09-28 12:16:01 +02:00
| `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`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM` ,
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è
la variabile `XPRV_ENCRYPTION_KEY` e la sintassi delle riserve non ha
`pin=` .
2026-09-28 12:16:01 +02:00
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF,
2026-09-28 15:51:13 +02:00
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 ).
2026-09-28 12:16:01 +02:00
*Codice:* `app/rounds/config.py:get_round_config` ·
`app/db/models.py:RoundConfig`
2026-09-28 15:51:13 +02:00
*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.
2026-09-28 12:16:01 +02:00
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
2026-09-28 15:51:13 +02:00
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
2026-09-28 12:16:01 +02:00
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
2026-09-28 15:51:13 +02:00
codice (vedi [decisioni-e-limiti.md ](decisioni-e-limiti.md )). Le conferme
dei depositi sono invece configurabili ([ARC-31]).
2026-09-28 12:16:01 +02:00
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
transazione, depositi e coinbase compresi.
2026-09-28 12:16:01 +02:00
## Aggiornamenti in tempo reale (SSE)
Le pagine si aggiornano da sole in due modi sovrapposti: il polling
periodico e, in più, un canale **Server-Sent Events** che le avvisa appena
qualcosa cambia.
```mermaid
flowchart LR
subgraph Processo
P1["round aperto / cambio di stato"] --> BR
P2["scommessa o prelievo inviato / annullato"] --> BR
P3["transazione confermata / abbandonata"] --> BR
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
P5["nuovo header"] --> BR
BR["broadcaster.publish()"] --> Q1["coda client 1<br/>(maxsize 1)"]
BR --> Q2["coda client N"]
end
Q1 -- "event: update" --> S1["EventSource /"]
Q2 -- "event: update" --> S2["EventSource /admin"]
S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"]
```
- **[ARC-33]** Il canale è pub/sub **in-process** : `broadcaster` è un unico
`RoundEventBroadcaster` per processo, con una `asyncio.Queue(maxsize=1)`
per client. `publish()` mette una notifica in ogni coda non piena; se la
coda è già piena la nuova notifica viene scartata, perché quella in attesa
basta già a far ricaricare tutto (notifiche ridondanti si fondono).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-34]** Il messaggio non ha contenuto e lo stream non ha
autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni
dato, compresa la personalizzazione (come `user_played` ), arriva dagli
endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato
di ogni evento, anche di quelli che non lo riguardano.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-35]** Limiti di sottoscrizione: al massimo `MAX_SUBSCRIBERS` (500)
stream in tutto, oltre i quali `GET /rounds/stream` risponde `503` e il
browser ricade sul polling; e al massimo `MAX_SUBSCRIBERS_PER_IP` (5) per
IP client, oltre i quali la connessione **più vecchia** di quello stesso IP
viene chiusa (le si invia il segnale `EVICTED` ) invece di rifiutare la
nuova. L'IP è quello di `client_ip` (vedi [sicurezza.md ](sicurezza.md )).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` ·
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-36]** Lo stream invia `event: update` con `data: {}` per ogni
notifica. Da fermo controlla `request.is_disconnected()` ogni 5 secondi e
invia un commento `: keep-alive` ogni 20 secondi (4 controlli), così né un
client sparito senza chiudere né il timeout di inattività di un proxy lo
lasciano appeso. La risposta porta `Cache-Control: no-store` e
`X-Accel-Buffering: no` . Alla chiusura il client viene sempre rimosso.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-37]** `publish()` viene chiamato in questi punti, e ogni nuovo
cambiamento di stato visibile deve aggiungersi a questo elenco:
- apertura di un round — `app/rounds/service.py:open_new_round_if_needed` ;
- ogni transizione di stato del round (`closing` , `closed` senza
partecipanti, `drawing` , `paying_out` ) —
`app/rounds/scheduler.py:RoundScheduler._tick` ,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ;
- scommessa inviata e scommessa annullata —
`app/bets/service.py:place_bet` , `app/bets/service.py:_release_failed_bet` ;
- prelievo inviato e prelievo annullato —
`app/withdrawals/service.py:request_withdrawal` ,
`app/withdrawals/service.py:_release_failed_withdrawal` ;
- transazione confermata — `app/tx/confirmation.py:poll_once` ;
- riga promossa o abbandonata dal riconciliatore —
2026-09-28 15:51:13 +02:00
`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;
2026-09-28 12:16:01 +02:00
- 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)
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
2026-09-28 12:16:01 +02:00
## 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
2026-09-28 15:51:13 +02:00
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).
2026-09-28 12:16:01 +02:00
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
2026-09-28 15:51:13 +02:00
*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.
2026-09-28 12:16:01 +02:00
### `/report-bug` e `/guida`
- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza
login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e
per mostrare i propri report (`GET /bug-reports/mine` ). Non usa SSE né
polling. Il flusso di triage è descritto in [api.md ](api.md ) e in
[guida-admin.md ](guida-admin.md ).
*Codice:* `app/static/report-bug.html`
- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida
completa sarà pubblicata qui a breve").
*Codice:* `app/static/guida.html`
## Internazionalizzazione
Riguarda `/` e `/report-bug` . `/admin` e `/guida` sono volutamente solo in
italiano.
- **[ARC-48]** Le lingue supportate sono sette: `en` , `it` , `es` , `fr` , `de` ,
`ru` , `zh` . Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in
`app/static/i18n.js` , caricato prima dello script della pagina; non ci sono
file separati né richieste di rete.
*Codice:* `app/static/i18n.js`
- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di
`localStorage.plm_lang` se supportato, le prime due lettere di
`navigator.language` se supportate, altrimenti `en` . `setLanguage` salva la
scelta in `plm_lang` , ritraduce la pagina e chiama `onLanguageChange` se la
pagina la definisce. Su `/` il selettore sta nella barra di stato della
catena, non nella barra di navigazione, perché quest'ultima è nascosta fino
al login.
*Codice:* `app/static/i18n.js:detectDefaultLang` ·
`app/static/i18n.js:setLanguage` · `app/static/index.html`
- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n`
(testo), `data-i18n-html` , `data-i18n-placeholder` , `data-i18n-title` ,
`data-i18n-aria-label` , `data-i18n-alt` , applicati da
`applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che
nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da
`onLanguageChange` . Un elemento usa **uno solo** dei due meccanismi, mai
entrambi (per esempio `#bet-btn` non ha `data-i18n` , perché la sua
etichetta contiene il costo configurabile).
*Codice:* `app/static/i18n.js:applyStaticTranslations` ·
`app/static/app.js:onLanguageChange` ·
`app/static/app.js:renderBetButton`
- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi** .
`t()` cerca la chiave nella lingua corrente, poi in `en` , e se manca anche
lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null` ,
così il chiamante può usare un testo di riserva. I parametri si
sostituiscono nei segnaposto `{nome}` .
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull`
- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail`
strutturato `{code, message, params}` (vedi [api.md ](api.md )); la
traduzione spetta al client. Per un `detail` oggetto il frontend mostra
`error.<code>` nella lingua corrente, con i parametri; a ogni parametro
`*_sats` numerico affianca un `*_plm` già formattato; se il codice non ha
traduzione mostra `message` . Un `detail` stringa viene mostrato così com'è;
un `detail` lista (errore di validazione 422 di FastAPI) diventa
`error.invalid_request` . Aggiungere un errore rivolto all'utente significa
dargli un codice e aggiungere `error.<codice>` in tutte e sette le lingue.
*Codice:* `app/static/app.js:apiErrorMessage` ·
`app/static/app.js:errorParams` · `app/api/errors.py:ApiError`
- **[ARC-53]** Su `/report-bug` l'avviso `bugReport.englishNotice` è tradotto
in tutte le lingue, ma dice sempre di scrivere la segnalazione in inglese,
perché il pannello di triage non è tradotto.
*Codice:* `app/static/report-bug.html` · `app/static/i18n.js`
## Test
- **[ARC-54]** La suite è tutta in `tests/unit/` (351 test raccolti);
`tests/integration/` contiene solo `__init__.py` . Si esegue con
`python -m pytest` dal venv. `pyproject.toml` imposta `testpaths = ["tests"]`
e `asyncio_mode = "auto"` , quindi i test asincroni non richiedono
`@pytest.mark.asyncio` . Dipendenze di sviluppo: `pytest` , `pytest-asyncio` ,
`httpx` .
*Codice:* `pyproject.toml` · *Test:* `tests/unit/test_docs_current.py`
- **[ARC-55]** Non esiste un `conftest.py` : ogni file definisce le proprie
fixture. Lo schema tipico di una fixture `session_factory` è: master key
generata in una directory temporanea con una chiave Fernet casuale (e
cache `hd._account_key` azzerata), engine SQLite in memoria con
`Base.metadata.create_all` (le migrazioni Alembic non vengono eseguite),
una riga `RoundConfig` con un `fee_address` valido dove serve aprire round.
I test HTTP costruiscono un'app FastAPI minima con i soli router
necessari, un `ElectrumListener` senza connessione su `app.state` , e la
interrogano con `httpx.AsyncClient` su `ASGITransport` : il `lifespan` di
`app/main.py` non viene eseguito.
*Test:* `tests/unit/test_bets.py` · `tests/unit/test_rounds_route.py`
- **[ARC-56]** La rete non viene mai contattata: al posto del client e del
listener Electrum ogni file usa classi finte locali, ad esempio
`FakeElectrumClient` (`test_bets.py` , `test_withdrawals.py` ,
`test_balance.py` ), `RejectingElectrumClient` (`test_bets.py` ),
`FakeClient` e `ExplodingClient` (`test_confirmation.py` ), `FakeClient`
(`test_broadcast.py` ), `UnknownTxClient` , `KnownTxClient` , `BrokenClient`
(`test_reconcile.py` ), `FakeListener` , `FakePayoutClient` ,
`FakePayoutListener` , `CorroboratingListener` , `LateTipListener` ,
`StallingListener` (`test_scheduler.py` ), `FakeListener`
(`test_deposit_reconcile.py` ), `FakeWriter` (`test_electrum_client.py` ).
*Test:* i file citati
- **[ARC-57]** Alcuni test verificano file che non sono codice Python,
perché nessun altro test li eserciterebbe:
- `test_docs_current.py` : il numero di test dichiarato in `CLAUDE.md` e
`README.md` coincide con quello raccolto; `CLAUDE.md` non contiene
affermazioni superate note; la mappa del codice di `CLAUDE.md` cita
`rate_limit.py` , `client_ip.py` e `bug_reports` ; i link relativi del
README e le ancore verso `CLAUDE.md` esistono;
- `test_docs_no_bare_uvicorn.py` : README e `docs/running-the-server.md`
non documentano un `uvicorn --reload` fuori da Docker;
- `test_code_comments.py` : alcuni commenti nel codice restano veri (per
esempio il numero di task avviati dal lifespan, sei, citato in
`app/db/base.py` );
- `test_caddyfile_security_headers.py` : il `Caddyfile` mantiene le
intestazioni di sicurezza e la sovrascrittura di `X-Forwarded-For` ;
- `test_withdrawals.py::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` :
[scommesse-e-prelievi.md ](scommesse-e-prelievi.md ) descrive correttamente
la convivenza di scommessa e prelievo.
Chi modifica questi file (compresi i documenti in `docs/` ) deve tenerne
conto.
*Test:* `tests/unit/test_docs_current.py` ·
`tests/unit/test_docs_no_bare_uvicorn.py` ·
`tests/unit/test_code_comments.py` ·
`tests/unit/test_caddyfile_security_headers.py` ·
`tests/unit/test_withdrawals.py`
2026-09-28 15:51:13 +02:00
- **[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 (lo schema dei 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 [decisioni-e-limiti.md ](decisioni-e-limiti.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:* —
*Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni.
- **[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 ](decisioni-e-limiti.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` .
2026-09-28 12:16:01 +02:00
## Assunzioni di processo singolo
- **[ARC-59]** Diversi componenti tengono stato nella memoria del processo e
funzionano correttamente solo con un unico processo uvicorn ([ARC-02]):
- il canale SSE (`broadcaster` ): un evento pubblicato in un processo non
raggiungerebbe i client collegati a un altro;
- i lock per utente (`UserLocks` ): due processi potrebbero costruire
insieme una scommessa e un prelievo dello stesso utente;
- i limitatori di login e registrazione (`AuthRateLimiters` su
`app.state` ): ogni processo conterebbe separatamente;
- l'`ElectrumListener` e i sei task: ogni processo aprirebbe la propria
connessione e il proprio scheduler, con due scheduler che avanzano lo
stesso round;
- le cache locali (chiave d'account decifrata in `app/wallet/hd.py` , QR
memorizzati in `app/api/routes/qr.py` ), che sarebbero solo duplicate.
Un deployment multi-processo richiederebbe un canale condiviso (per
esempio Redis pub/sub) e un lock su database o Redis. Fa eccezione
l'invariante "al massimo un round attivo", garantita da un indice del
database e quindi valida anche con più processi (vedi
[modello-dati.md ](modello-dati.md )). La scelta è discussa in
[decisioni-e-limiti.md ](decisioni-e-limiti.md ).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
`app/tx/locks.py:UserLocks` · `app/auth/rate_limit.py:AuthRateLimiters` ·
`app/main.py:lifespan`