Files
plm-lottery/docs/architettura.md
T
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:16:01 +02:00

799 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architettura
Come è fatto il software dall'interno: quali processi girano, come è
organizzato il codice, cosa succede all'avvio, quali attività lavorano in
background, dove sta la configurazione, come le pagine web restano
aggiornate e come è strutturata la suite di test.
Questo documento non descrive i flussi di dominio (scommesse, round,
depositi…): per quelli rimanda ai documenti che li possiedono, elencati in
[come-funziona.md](come-funziona.md). Le tabelle del database sono in
[modello-dati.md](modello-dati.md), gli endpoint in [api.md](api.md), le
difese di sicurezza in [sicurezza.md](sicurezza.md).
## Panoramica del deployment
```mermaid
flowchart LR
B["Browser<br/>(/, /admin, /report-bug)"] -- "HTTPS 443<br/>(80 → redirect)" --> C["Container caddy<br/>reverse proxy + TLS"]
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
A -- "scrive" --> L["./data/logs/app.log"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
- **[ARC-01]** Il server gira sempre in Docker, con lo stesso
`docker-compose.yml` in sviluppo e in produzione: due container, `app`
(questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo
`SITE_ADDRESS`: non impostato vale `localhost` (certificato autofirmato
dalla CA interna di Caddy), impostato a un dominio produce un certificato
Let's Encrypt. Non esiste un modo supportato di lanciare `uvicorn` fuori da
Docker.
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
`--workers`), in ascolto su `0.0.0.0:8123`. Tutto lo stato in memoria
descritto in [Assunzioni di processo singolo](#assunzioni-di-processo-singolo)
dipende da questo.
*Codice:* `Dockerfile` (`CMD`)
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
`exec`. Le migrazioni non si lanciano mai a mano.
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
443. Su questo si basa la lettura dell'IP del client (vedi
[sicurezza.md](sicurezza.md)). Caddy non comprime `/rounds/stream`, perché
la compressione accumulerebbe gli eventi SSE invece di consegnarli subito.
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`
- **[ARC-05]** I dati persistenti stanno in `./data/` sull'host, montata nel
container: `./data/db` → `/app/db_data`, `./data/keys` → `/app/key_data`,
`./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e
`MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili
arrivano da `.env` (`env_file`). Entrambi i container hanno
`restart: unless-stopped`.
*Codice:* `docker-compose.yml`
### Database
- **[ARC-06]** Il database è SQLite, usato tramite SQLAlchemy 2 in modalità
asincrona (`create_async_engine` su `aiosqlite`). Una sola `engine` e una
sola fabbrica di sessioni (`AsyncSessionLocal`, con
`expire_on_commit=False`) servono sia le richieste HTTP (dipendenza
`get_session`) sia le attività in background, che aprono ciascuna le
proprie sessioni.
*Codice:* `app/db/base.py` · `app/db/session.py:get_session`
- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati
`journal_mode=WAL`, `synchronous=NORMAL` e `busy_timeout=5000` ms: in WAL
lettori e scrittori non si bloccano a vicenda, e uno scrittore che trova il
database occupato aspetta fino a 5 secondi invece di fallire subito. Con un
dialetto diverso da SQLite le pragma non vengono applicate.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
tramite migrazioni Alembic in `migrations/versions/` (generate con
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
database da `settings.database_url`. I campi delle tabelle sono descritti
in [modello-dati.md](modello-dati.md).
*Codice:* `migrations/env.py` · `app/db/models.py`
### Log
- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`,
`uvicorn.access`) finiscono in `logs/app.log` (nel container
`/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da
10 MB con 5 file di storico, livello `INFO`. La configurazione avviene
all'import di `app/main.py`, prima di qualunque altro modulo applicativo.
*Codice:* `app/logging_config.py:setup_logging`
## Mappa del codice
Ogni file sotto `app/`, raggruppato per pacchetto. I file `__init__.py`
sono vuoti e non sono elencati.
### Radice e infrastruttura
| File | Responsabilità |
|---|---|
| `app/main.py` | Punto d'ingresso: `lifespan` (validazione segreti, avvio e arresto dei sei task), inclusione dei router, gestore degli errori non previsti, pagine HTML, `/health`, mount dei file statici |
| `app/config.py` | `Settings` (variabili d'ambiente / `.env`), istanza `settings`, `validate_runtime_secrets`, `ConfigError` |
| `app/logging_config.py` | `setup_logging`: file di log a rotazione |
| `app/db/base.py` | `engine`, `AsyncSessionLocal`, pragma SQLite, `Base` dichiarativa |
| `app/db/session.py` | `get_session`, dipendenza FastAPI che apre una sessione per richiesta |
| `app/db/models.py` | Tutte le tabelle (`User`, `UtxoEvent`, `Round`, `RoundParticipant`, `RoundConfig`, `PendingTransaction`, `Withdrawal`, `BugReport`, `AuditLog`) e l'indice del round attivo unico — vedi [modello-dati.md](modello-dati.md) |
| `app/audit/log.py` | `write_audit_log`: aggiunge una riga al registro di audit nella sessione del chiamante (il commit resta al chiamante) — catalogo eventi in [audit-log.md](audit-log.md) |
### API HTTP
| File | Responsabilità |
|---|---|
| `app/api/errors.py` | `ApiError` (codice + messaggio inglese + parametri), `http_error`, `from_api_error`: il formato strutturato di `detail` — vedi [api.md](api.md) |
| `app/api/client_ip.py` | `client_ip`: IP reale del chiamante (ultimo elemento di `X-Forwarded-For`), usato da tutti i controlli basati sull'IP |
| `app/api/timeutil.py` | `isoformat_utc`: serializza le date come UTC esplicito (SQLite le restituisce senza fuso) |
| `app/api/routes/admin.py` | `/admin/*`: `require_admin` (token condiviso), configurazione, pausa/ripresa, utenti, export WIF, reset password, round, transazioni pendenti, audit log, bug report |
| `app/api/routes/bets.py` | `POST /bets`: controllo connessione, lock per utente, chiamata a `place_bet` |
| `app/api/routes/withdrawals.py` | `POST /withdrawals`: stessa struttura, chiama `request_withdrawal` |
| `app/api/routes/rounds.py` | `GET /rounds/current` e il canale SSE `GET /rounds/stream` |
| `app/api/routes/users.py` | `GET /users/me`, cambio password, `GET /users/me/last-round-result` |
| `app/api/routes/qr.py` | `GET /qr/{address}`: PNG del QR, indirizzo validato, rendering memorizzato e fuori dall'event loop |
| `app/api/routes/bug_reports.py` | `POST /bug-reports`, `GET /bug-reports/mine` |
### Autenticazione
| File | Responsabilità |
|---|---|
| `app/auth/routes.py` | `/auth/register`, `/auth/login`; notifica al listener il nuovo indirizzo da osservare |
| `app/auth/security.py` | Argon2 (versioni sincrone e `*_async` su threadpool), creazione e decodifica JWT, `MIN_PASSWORD_LENGTH` |
| `app/auth/dependencies.py` | `get_current_user`, `get_optional_user` (controllo del claim `tv`) |
| `app/auth/rate_limit.py` | `RateLimiter`, `RollingQuota`, `AuthRateLimiters` (tenuti su `app.state`) — vedi [sicurezza.md](sicurezza.md) |
### Wallet e chiavi
| File | Responsabilità |
|---|---|
| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` |
| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv |
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo |
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS` |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints` |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md) |
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
### Dominio
| File | Responsabilità |
|---|---|
| `app/deposits/service.py` | Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino |
| `app/deposits/reconcile.py` | `DepositReconciler`: ricontrollo periodico di tutti gli indirizzi |
| `app/bets/service.py` | `place_bet`, `BetError`, rilascio di una scommessa fallita |
| `app/bets/confirmation.py` | Gestore di conferma per il tipo `bet` |
| `app/withdrawals/service.py` | `request_withdrawal`, `WithdrawalError`, rilascio di un prelievo fallito |
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
Flussi descritti in [scommesse-e-prelievi.md](scommesse-e-prelievi.md),
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
[registrazione-e-depositi.md](registrazione-e-depositi.md).
### Transazioni
| File | Responsabilità |
|---|---|
| `app/tx/confirmation.py` | `ConfirmationPoller`, `poll_once`, registro dei gestori (`register_handler`) |
| `app/tx/broadcast.py` | `RbfBumper`, `should_bump`, `bump_fee` |
| `app/tx/reconcile.py` | `PendingTransactionReconciler`, `reconcile_once`: promozione o abbandono delle righe `building`/`pending` |
| `app/tx/pending_address.py` | `own_address_for`: indirizzo proprietario degli input di una transazione (utente o pool) |
| `app/tx/locks.py` | `UserLocks`: lock asincrono per utente |
Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
### Frontend statici
| File | Responsabilità |
|---|---|
| `app/static/index.html` | Pagina `/` per i giocatori |
| `app/static/app.js` | Logica di `/`: autenticazione, pannelli, polling, SSE, rivelazione del risultato |
| `app/static/style.css` | Stile di `/` (e di `/report-bug`) |
| `app/static/i18n.js` | Tabella `TRANSLATIONS` in 7 lingue, `t()`, `tOrNull()`, `setLanguage()`, `applyStaticTranslations()` |
| `app/static/report-bug.html` | Pagina `/report-bug`, con il proprio script inline |
| `app/static/admin.html` | Pannello `/admin` |
| `app/static/admin.js` | Logica del pannello: token, sezioni, polling dello stato catena, SSE |
| `app/static/admin.css` | Stile del pannello |
| `app/static/guida.html` | Pagina `/guida`, per ora un segnaposto |
### Dipendenze tra pacchetti
Il grafo riporta gli import tra i pacchetti di `app/` (a livello di pacchetto,
non di singolo modulo). `db` e `config` sono importati quasi ovunque e sono
omessi dalle frecce per leggibilità.
```mermaid
flowchart TD
main["main"] --> routes["api.routes"]
main --> auth
main --> tx
main --> rounds
main --> electrum
main --> deposits
main --> bets
main --> withdrawals
routes --> auth
routes --> bets
routes --> withdrawals
routes --> rounds
routes --> wallet
routes --> audit
routes --> apiutil["api (errors, client_ip, timeutil)"]
auth --> apiutil
auth --> wallet
bets --> apiutil
bets --> rounds
bets --> wallet
bets --> audit
bets -- "register_handler" --> tx
withdrawals --> apiutil
withdrawals --> rounds
withdrawals --> wallet
withdrawals --> audit
withdrawals -- "register_handler" --> tx
rounds --> electrum
rounds --> wallet
rounds --> audit
rounds -- "register_handler" --> tx
electrum --> deposits
electrum -- "draw (header math), events" --> rounds
deposits --> electrum
deposits --> wallet
deposits --> audit
deposits -- "events" --> rounds
tx --> wallet
tx --> electrum
tx --> audit
tx -- "events, config" --> rounds
```
- **[ARC-10]** Il poller delle conferme (`app/tx/confirmation.py`) non importa
i moduli di dominio: sono `app/bets/confirmation.py`,
`app/rounds/confirmation.py` e `app/withdrawals/confirmation.py` a
registrarsi con `register_handler(kind, handler)` al momento dell'import,
per i tipi `bet`, `payout` e `withdrawal`.
*Codice:* `app/tx/confirmation.py:register_handler` ·
`app/bets/confirmation.py` · `app/rounds/confirmation.py` ·
`app/withdrawals/confirmation.py` ·
*Test:* `tests/unit/test_confirmation.py`
- **[ARC-11]** Gli errori di dominio destinati all'utente sono sottoclassi di
`ApiError` (`BetError`, `WithdrawalError`) sollevate direttamente dai
servizi, con il codice che il frontend traduce; le route li convertono in
`HTTPException` scegliendo lo stato HTTP. Per questo `bets` e
`withdrawals` dipendono da `app/api/errors.py`.
*Codice:* `app/api/errors.py:ApiError` · `app/bets/service.py:BetError` ·
`app/withdrawals/service.py:WithdrawalError`
## Avvio del processo
`app/main.py` fa due cose distinte: **all'import** costruisce l'applicazione
FastAPI; **all'avvio del server** (il `lifespan`) prepara lo stato condiviso
e lancia le attività in background.
```mermaid
sequenceDiagram
participant U as uvicorn
participant M as app/main.py (import)
participant L as lifespan
U->>M: import app.main
M->>M: setup_logging()
M->>M: import dei gestori di conferma (bet, payout, withdrawal)
M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
U->>L: startup
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste
U->>L: shutdown
L->>L: cancel() dei 6 task, chiusura del client Electrum
```
### All'import
- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa
di `app/main.py`, prima di importare qualunque altro modulo `app.*`, così
anche i log emessi durante gli import finiscono nel file.
*Codice:* `app/main.py` · `app/logging_config.py:setup_logging`
- **[ARC-13]** `app/main.py` importa `app.bets.confirmation`,
`app.rounds.confirmation` e `app.withdrawals.confirmation` **solo per il
loro effetto collaterale** (la registrazione del gestore, vedi
[ARC-10]). Questi import non vanno rimossi: senza gestore, il poller
segnerebbe comunque la `PendingTransaction` come `confirmed`, ma senza
aggiornare il partecipante, il round o il prelievo corrispondente.
*Codice:* `app/main.py` · `app/tx/confirmation.py:poll_once`
- **[ARC-14]** La documentazione automatica (`/docs`, `/redoc`,
`/openapi.json`) è disattivata a meno che `ENABLE_API_DOCS` sia vero.
*Codice:* `app/main.py` · *Test:* `tests/unit/test_docs_disabled.py`
- **[ARC-15]** I router vengono inclusi in quest'ordine: `auth`, `users`,
`bets`, `withdrawals`, `admin`, `bug_reports`, `qr`, `rounds`. Gli endpoint
sono in [api.md](api.md).
*Codice:* `app/main.py`
- **[ARC-16]** Qualunque eccezione non gestita produce una risposta 500 con
lo stesso formato degli errori voluti
(`{"detail": {"code": "internal_error", "message": "internal server error", "params": {}}}`);
il dettaglio dell'eccezione va solo nel log, mai nella risposta.
*Codice:* `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py`
- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare
database né rete.
*Codice:* `app/main.py:health`
- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il
file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` →
`admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`.
Per ultimo, l'intera cartella `app/static/` è montata su `/` con
`StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…). I percorsi sono relativi
alla directory di lavoro, che nel container è `/app`.
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
`app/main.py:user_guide` · `app/main.py:report_bug_page`
### Nel lifespan
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32
caratteri o `XPRV_ENCRYPTION_KEY` è vuota, riportando tutti i problemi
insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello resta
semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore
di `Settings`, così import e test non hanno bisogno di segreti veri.
*Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py`
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`; un
elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene
scritto nel log.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
alla prima richiesta di registrazione o login che ne ha bisogno.
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento
ciascun task riceve `cancel()` (senza attenderne la fine) e il client
Electrum, se presente, viene chiuso.
*Codice:* `app/main.py:lifespan` · *Test:* `tests/unit/test_code_comments.py`
## Attività in background
| Task | Costruito con | Cadenza | Ruolo |
|---|---|---|---|
| `ElectrumListener` | `_make_electrum_client`, `AsyncSessionLocal`, endpoint | ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi |
| `RoundScheduler` | `AsyncSessionLocal`, `listener` | 5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti |
| `ConfirmationPoller` | `AsyncSessionLocal`, `lambda: listener.client` | 10s | `pending` → `confirmed` e gestore per tipo |
| `RbfBumper` | `AsyncSessionLocal`, `lambda: listener.client` | 30s | Aumenta la commissione di ciò che è `pending` oltre `rbf_timeout_seconds` |
| `PendingTransactionReconciler` | `AsyncSessionLocal`, `lambda: listener.client` | subito, poi 120s | Promuove o abbandona le righe `building`/`pending` |
| `DepositReconciler` | `AsyncSessionLocal`, `listener` | 300s, attende prima del primo giro | `refresh_user` su ogni indirizzo |
- **[ARC-23]** **Accesso al client Electrum.** Nessun task tiene un
riferimento fisso al client: `ConfirmationPoller`, `RbfBumper` e
`PendingTransactionReconciler` ricevono `lambda: listener.client` e lo
rileggono a ogni giro; `RoundScheduler` e `DepositReconciler` ricevono il
listener intero e leggono `listener.client` (il secondo anche per chiamare
`refresh_user`). Così una riconnessione sostituisce il client sotto tutti.
Un client non nullo implica che l'altezza della catena sia già nota (vedi
[connessione-electrum.md](connessione-electrum.md)).
*Codice:* `app/main.py:lifespan`
- **[ARC-24]** **Client assente.** Quando `listener.client` è `None` ogni task
salta il giro e riprova alla cadenza successiva; `DepositReconciler`
interrompe anche una scansione in corso. Le route che devono trasmettere
(`POST /bets`, `POST /withdrawals`) rispondono invece `503`
`network_unavailable`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/tx/confirmation.py:ConfirmationPoller.run` ·
`app/tx/broadcast.py:RbfBumper.run` ·
`app/tx/reconcile.py:PendingTransactionReconciler.run` ·
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
- **[ARC-25]** **Errori.** Ogni ciclo intercetta qualunque `Exception` di un
giro, la scrive nel log con lo stack e riprende alla cadenza successiva;
solo `CancelledError` (lo spegnimento) esce dal ciclo. All'interno di un
giro, poller, bumper, riconciliatore e `DepositReconciler` isolano anche il
singolo elemento: una transazione o un utente che fallisce non ferma gli
altri. Non esiste un supervisore: un task che terminasse non verrebbe
riavviato (l'unico caso previsto è il listener senza alcun endpoint, che
scrive un errore ed esce; `parse_endpoints` include però sempre il server
primario).
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run` ·
`app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:RbfBumper._tick` ·
`app/tx/reconcile.py:reconcile_once` ·
`app/deposits/reconcile.py:DepositReconciler.run` ·
`app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_reconcile.py` ·
`tests/unit/test_deposit_reconcile.py`
- **[ARC-26]** **`ElectrumListener`.** Dopo ogni sessione terminata passa
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
scatta solo quando tutti i server hanno fallito nello stesso giro.
Rotazione, keepalive, validazione e corroborazione sono in
[connessione-electrum.md](connessione-electrum.md).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo. L'estrazione
(`_close_and_draw`, con l'attesa del blocco successivo) viene eseguita
**dentro** il giro: finché dura, lo scheduler non fa altro. Dopo un riavvio
un round rimasto in `drawing` non viene ripreso (limite accettato, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)). Logica del round in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions`; il riconciliatore parte
subito perché un crash durante un invio lascia proprio una riga `building`
da risolvere al riavvio. Regole in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/tx/confirmation.py:ConfirmationPoller` ·
`app/tx/broadcast.py:RbfBumper` ·
`app/tx/reconcile.py:PendingTransactionReconciler` ·
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_broadcast.py` ·
`tests/unit/test_reconcile.py`
- **[ARC-29]** **`DepositReconciler`** attende 300s, poi scorre tutti gli
utenti e chiama `listener.refresh_user` su ciascuno: lo stesso codice usato
dalle notifiche, così i due percorsi non possono divergere.
*Codice:* `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
## Configurazione
La configurazione è divisa in due posti con regole diverse.
- **[ARC-30]** **Segreti e collegamenti infrastrutturali** stanno nelle
variabili d'ambiente (file `.env`, letto da `Settings`; variabili sconosciute
ignorate). `settings` è costruito una volta all'import di `app/config.py`:
una modifica richiede il riavvio del container. I campi sono:
| Campo (`Settings`) | Variabile | Predefinito | Uso |
|---|---|---|---|
| `database_url` | `DATABASE_URL` | `sqlite+aiosqlite:///./plm_lottery.db` | URL SQLAlchemy (in Docker sovrascritto dal compose) |
| `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario |
| `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario |
| `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario |
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls]` separati da virgola |
| `xprv_encryption_key` | `XPRV_ENCRYPTION_KEY` | `""` | Chiave Fernet della master xprv (obbligatoria, [ARC-19]) |
| `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) |
| `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) |
| `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT |
| `jwt_expire_minutes` | `JWT_EXPIRE_MINUTES` | `1440` (24h) | Durata del token |
| `admin_token` | `ADMIN_TOKEN` | `""` | Token del pannello `/admin`; vuoto = pannello chiuso |
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF,
indirizzo commissioni, pausa di manutenzione) stanno nell'unica riga della
tabella `round_config`, modificabile a caldo da `/admin` senza riavvio. I
valori iniziali sono i default delle colonne di `RoundConfig`, non
`app/config.py`. Se la riga manca, viene creata al primo accesso con
`fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md), effetti
in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/config.py:get_round_config` ·
`app/db/models.py:RoundConfig`
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste (1), i limiti di input e di
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
## Aggiornamenti in tempo reale (SSE)
Le pagine si aggiornano da sole in due modi sovrapposti: il polling
periodico e, in più, un canale **Server-Sent Events** che le avvisa appena
qualcosa cambia.
```mermaid
flowchart LR
subgraph Processo
P1["round aperto / cambio di stato"] --> BR
P2["scommessa o prelievo inviato / annullato"] --> BR
P3["transazione confermata / abbandonata"] --> BR
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
P5["nuovo header"] --> BR
BR["broadcaster.publish()"] --> Q1["coda client 1<br/>(maxsize 1)"]
BR --> Q2["coda client N"]
end
Q1 -- "event: update" --> S1["EventSource /"]
Q2 -- "event: update" --> S2["EventSource /admin"]
S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"]
```
- **[ARC-33]** Il canale è pub/sub **in-process**: `broadcaster` è un unico
`RoundEventBroadcaster` per processo, con una `asyncio.Queue(maxsize=1)`
per client. `publish()` mette una notifica in ogni coda non piena; se la
coda è già piena la nuova notifica viene scartata, perché quella in attesa
basta già a far ricaricare tutto (notifiche ridondanti si fondono).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-34]** Il messaggio non ha contenuto e lo stream non ha
autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni
dato, compresa la personalizzazione (come `user_played`), arriva dagli
endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato
di ogni evento, anche di quelli che non lo riguardano.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-35]** Limiti di sottoscrizione: al massimo `MAX_SUBSCRIBERS` (500)
stream in tutto, oltre i quali `GET /rounds/stream` risponde `503` e il
browser ricade sul polling; e al massimo `MAX_SUBSCRIBERS_PER_IP` (5) per
IP client, oltre i quali la connessione **più vecchia** di quello stesso IP
viene chiusa (le si invia il segnale `EVICTED`) invece di rifiutare la
nuova. L'IP è quello di `client_ip` (vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` ·
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
- **[ARC-36]** Lo stream invia `event: update` con `data: {}` per ogni
notifica. Da fermo controlla `request.is_disconnected()` ogni 5 secondi e
invia un commento `: keep-alive` ogni 20 secondi (4 controlli), così né un
client sparito senza chiudere né il timeout di inattività di un proxy lo
lasciano appeso. La risposta porta `Cache-Control: no-store` e
`X-Accel-Buffering: no`. Alla chiusura il client viene sempre rimosso.
*Codice:* `app/api/routes/rounds.py:round_stream`
- **[ARC-37]** `publish()` viene chiamato in questi punti, e ogni nuovo
cambiamento di stato visibile deve aggiungersi a questo elenco:
- apertura di un round — `app/rounds/service.py:open_new_round_if_needed`;
- ogni transizione di stato del round (`closing`, `closed` senza
partecipanti, `drawing`, `paying_out`) —
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`;
- scommessa inviata e scommessa annullata —
`app/bets/service.py:place_bet`, `app/bets/service.py:_release_failed_bet`;
- prelievo inviato e prelievo annullato —
`app/withdrawals/service.py:request_withdrawal`,
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`;
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
`app/deposits/service.py:reinstate_reappeared_utxos`;
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
## Lock per utente
- **[ARC-38]** `UserLocks` tiene un `asyncio.Lock` per `user_id` (creato alla
prima richiesta di quell'utente e mai rimosso). `POST /bets` e
`POST /withdrawals` eseguono l'intera costruzione e trasmissione dentro
`app.state.user_locks.acquire(user.id)`: per lo stesso utente una scommessa
e un prelievo non sono mai in costruzione insieme, perché spendono dallo
stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi;
cosa implica è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md). Il
controllo "client connesso" avviene prima di acquisire il lock.
*Codice:* `app/tx/locks.py:UserLocks.acquire` ·
`app/api/routes/bets.py:create_bet` ·
`app/api/routes/withdrawals.py:create_withdrawal`
## Frontend
- **[ARC-39]** Le interfacce sono pagine statiche in `app/static/`, servite
direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano,
senza passo di build, framework né bundler. Parlano tutte con la stessa API
JSON; l'unica separazione tra utente e amministratore è `require_admin` sul
lato server.
*Codice:* `app/main.py` · `app/static/`
### `/` — giocatori
- **[ARC-40]** **Polling.** Da non autenticati, la pagina interroga
`GET /rounds/current` ogni 15s solo per la barra di stato della catena. Da
autenticati: `refreshRound` ogni 15s, ogni 3s mentre il round è in
`closing`/`drawing`/`paying_out`; `checkLastRoundResult`
(`GET /users/me/last-round-result`) ogni 20s; il conto alla rovescia
locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano
subito (i timer delle schede in background vengono rallentati dal
browser).
*Codice:* `app/static/app.js:startChainOnlyPolling` ·
`app/static/app.js:scheduleNextRoundPoll` ·
`app/static/app.js:showDashboard`
- **[ARC-41]** **Richieste.** Ogni chiamata API di `/` ha un timeout di 15s;
dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione
persa". Una risposta `401` con codice `session_expired` riporta al login.
*Codice:* `app/static/app.js:call` · `app/static/app.js:noteFetchOutcome`
- **[ARC-42]** **SSE.** La pagina apre un solo `EventSource('/rounds/stream')`
e reagisce sia agli eventi `update` sia a `open` (che scatta alla prima
connessione e a ogni riconnessione automatica): da autenticati ricarica
round, profilo e ultimo risultato, altrimenti solo lo stato della catena.
Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina
funziona come prima, solo senza l'avviso immediato.
*Codice:* `app/static/app.js:connectRoundEvents` ·
`app/static/app.js:onRoundServerEvent`
- **[ARC-43]** **Chiavi di `localStorage`** usate da `/`:
| Chiave | Contenuto |
|---|---|
| `plm_token` | JWT della sessione |
| `plm_username` | Nome utente mostrato |
| `plm_address` | Indirizzo di deposito |
| `plm_persisted_result` | Risultato del round rivelato (`round_id`, `won`, `amount_sats`), per ridisegnarlo dopo un ricaricamento |
| `plm_lang` | Lingua scelta (condivisa con `/report-bug`) |
Il logout svuota `localStorage` tranne `plm_lang`. La rimozione di
`plm_token` in una scheda riporta al login anche le altre schede aperte
(evento `storage`). La logica della rivelazione del risultato è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/static/app.js:logout` · `app/static/app.js:persistResult` ·
`app/static/i18n.js:setLanguage`
### `/admin` — operatore
- **[ARC-44]** Il pannello chiede il token operatore, lo verifica con
`GET /admin/config` e lo conserva in `sessionStorage` (`plm_admin_token`,
quindi si perde chiudendo la scheda); ogni chiamata lo invia
nell'intestazione `X-Admin-Token`. Non è collegato da `/` né lo collega.
*Codice:* `app/static/admin.js:callAdmin`
- **[ARC-45]** Il pannello interroga `GET /rounds/current` ogni 15s per la
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
aperte; non hanno un polling periodico proprio. La sezione Parametri non
viene ricaricata dagli eventi.
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
### `/report-bug` e `/guida`
- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza
login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e
per mostrare i propri report (`GET /bug-reports/mine`). Non usa SSE né
polling. Il flusso di triage è descritto in [api.md](api.md) e in
[guida-admin.md](guida-admin.md).
*Codice:* `app/static/report-bug.html`
- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida
completa sarà pubblicata qui a breve").
*Codice:* `app/static/guida.html`
## Internazionalizzazione
Riguarda `/` e `/report-bug`. `/admin` e `/guida` sono volutamente solo in
italiano.
- **[ARC-48]** Le lingue supportate sono sette: `en`, `it`, `es`, `fr`, `de`,
`ru`, `zh`. Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in
`app/static/i18n.js`, caricato prima dello script della pagina; non ci sono
file separati né richieste di rete.
*Codice:* `app/static/i18n.js`
- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di
`localStorage.plm_lang` se supportato, le prime due lettere di
`navigator.language` se supportate, altrimenti `en`. `setLanguage` salva la
scelta in `plm_lang`, ritraduce la pagina e chiama `onLanguageChange` se la
pagina la definisce. Su `/` il selettore sta nella barra di stato della
catena, non nella barra di navigazione, perché quest'ultima è nascosta fino
al login.
*Codice:* `app/static/i18n.js:detectDefaultLang` ·
`app/static/i18n.js:setLanguage` · `app/static/index.html`
- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n`
(testo), `data-i18n-html`, `data-i18n-placeholder`, `data-i18n-title`,
`data-i18n-aria-label`, `data-i18n-alt`, applicati da
`applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che
nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da
`onLanguageChange`. Un elemento usa **uno solo** dei due meccanismi, mai
entrambi (per esempio `#bet-btn` non ha `data-i18n`, perché la sua
etichetta contiene il costo configurabile).
*Codice:* `app/static/i18n.js:applyStaticTranslations` ·
`app/static/app.js:onLanguageChange` ·
`app/static/app.js:renderBetButton`
- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi**.
`t()` cerca la chiave nella lingua corrente, poi in `en`, e se manca anche
lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null`,
così il chiamante può usare un testo di riserva. I parametri si
sostituiscono nei segnaposto `{nome}`.
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull`
- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail`
strutturato `{code, message, params}` (vedi [api.md](api.md)); la
traduzione spetta al client. Per un `detail` oggetto il frontend mostra
`error.<code>` nella lingua corrente, con i parametri; a ogni parametro
`*_sats` numerico affianca un `*_plm` già formattato; se il codice non ha
traduzione mostra `message`. Un `detail` stringa viene mostrato così com'è;
un `detail` lista (errore di validazione 422 di FastAPI) diventa
`error.invalid_request`. Aggiungere un errore rivolto all'utente significa
dargli un codice e aggiungere `error.<codice>` in tutte e sette le lingue.
*Codice:* `app/static/app.js:apiErrorMessage` ·
`app/static/app.js:errorParams` · `app/api/errors.py:ApiError`
- **[ARC-53]** Su `/report-bug` l'avviso `bugReport.englishNotice` è tradotto
in tutte le lingue, ma dice sempre di scrivere la segnalazione in inglese,
perché il pannello di triage non è tradotto.
*Codice:* `app/static/report-bug.html` · `app/static/i18n.js`
## Test
- **[ARC-54]** La suite è tutta in `tests/unit/` (351 test raccolti);
`tests/integration/` contiene solo `__init__.py`. Si esegue con
`python -m pytest` dal venv. `pyproject.toml` imposta `testpaths = ["tests"]`
e `asyncio_mode = "auto"`, quindi i test asincroni non richiedono
`@pytest.mark.asyncio`. Dipendenze di sviluppo: `pytest`, `pytest-asyncio`,
`httpx`.
*Codice:* `pyproject.toml` · *Test:* `tests/unit/test_docs_current.py`
- **[ARC-55]** Non esiste un `conftest.py`: ogni file definisce le proprie
fixture. Lo schema tipico di una fixture `session_factory` è: master key
generata in una directory temporanea con una chiave Fernet casuale (e
cache `hd._account_key` azzerata), engine SQLite in memoria con
`Base.metadata.create_all` (le migrazioni Alembic non vengono eseguite),
una riga `RoundConfig` con un `fee_address` valido dove serve aprire round.
I test HTTP costruiscono un'app FastAPI minima con i soli router
necessari, un `ElectrumListener` senza connessione su `app.state`, e la
interrogano con `httpx.AsyncClient` su `ASGITransport`: il `lifespan` di
`app/main.py` non viene eseguito.
*Test:* `tests/unit/test_bets.py` · `tests/unit/test_rounds_route.py`
- **[ARC-56]** La rete non viene mai contattata: al posto del client e del
listener Electrum ogni file usa classi finte locali, ad esempio
`FakeElectrumClient` (`test_bets.py`, `test_withdrawals.py`,
`test_balance.py`), `RejectingElectrumClient` (`test_bets.py`),
`FakeClient` e `ExplodingClient` (`test_confirmation.py`), `FakeClient`
(`test_broadcast.py`), `UnknownTxClient`, `KnownTxClient`, `BrokenClient`
(`test_reconcile.py`), `FakeListener`, `FakePayoutClient`,
`FakePayoutListener`, `CorroboratingListener`, `LateTipListener`,
`StallingListener` (`test_scheduler.py`), `FakeListener`
(`test_deposit_reconcile.py`), `FakeWriter` (`test_electrum_client.py`).
*Test:* i file citati
- **[ARC-57]** Alcuni test verificano file che non sono codice Python,
perché nessun altro test li eserciterebbe:
- `test_docs_current.py`: il numero di test dichiarato in `CLAUDE.md` e
`README.md` coincide con quello raccolto; `CLAUDE.md` non contiene
affermazioni superate note; la mappa del codice di `CLAUDE.md` cita
`rate_limit.py`, `client_ip.py` e `bug_reports`; i link relativi del
README e le ancore verso `CLAUDE.md` esistono;
- `test_docs_no_bare_uvicorn.py`: README e `docs/running-the-server.md`
non documentano un `uvicorn --reload` fuori da Docker;
- `test_code_comments.py`: alcuni commenti nel codice restano veri (per
esempio il numero di task avviati dal lifespan, sei, citato in
`app/db/base.py`);
- `test_caddyfile_security_headers.py`: il `Caddyfile` mantiene le
intestazioni di sicurezza e la sovrascrittura di `X-Forwarded-For`;
- `test_withdrawals.py::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive`:
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) descrive correttamente
la convivenza di scommessa e prelievo.
Chi modifica questi file (compresi i documenti in `docs/`) deve tenerne
conto.
*Test:* `tests/unit/test_docs_current.py` ·
`tests/unit/test_docs_no_bare_uvicorn.py` ·
`tests/unit/test_code_comments.py` ·
`tests/unit/test_caddyfile_security_headers.py` ·
`tests/unit/test_withdrawals.py`
- **[ARC-58]** Ciò che la suite **non** copre: nessun test contro una
connessione Electrum reale; il `lifespan` e l'avvio dei sei task; la route
SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua
acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei
frontend; la parità delle chiavi tra le lingue di `i18n.js`. Le verifiche
dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni
reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
## Assunzioni di processo singolo
- **[ARC-59]** Diversi componenti tengono stato nella memoria del processo e
funzionano correttamente solo con un unico processo uvicorn ([ARC-02]):
- il canale SSE (`broadcaster`): un evento pubblicato in un processo non
raggiungerebbe i client collegati a un altro;
- i lock per utente (`UserLocks`): due processi potrebbero costruire
insieme una scommessa e un prelievo dello stesso utente;
- i limitatori di login e registrazione (`AuthRateLimiters` su
`app.state`): ogni processo conterebbe separatamente;
- l'`ElectrumListener` e i sei task: ogni processo aprirebbe la propria
connessione e il proprio scheduler, con due scheduler che avanzano lo
stesso round;
- le cache locali (chiave d'account decifrata in `app/wallet/hd.py`, QR
memorizzati in `app/api/routes/qr.py`), che sarebbero solo duplicate.
Un deployment multi-processo richiederebbe un canale condiviso (per
esempio Redis pub/sub) e un lock su database o Redis. Fa eccezione
l'invariante "al massimo un round attivo", garantita da un indice del
database e quindi valida anche con più processi (vedi
[modello-dati.md](modello-dati.md)). La scelta è discussa in
[decisioni-e-limiti.md](decisioni-e-limiti.md).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` ·
`app/tx/locks.py:UserLocks` · `app/auth/rate_limit.py:AuthRateLimiters` ·
`app/main.py:lifespan`