Finishes what 88221b0 started: sicurezza.md, audit-log.md and storico-bug.md
now describe the target behaviour too, the operator guides are corrected
against today's code, and api.md gains the missing /health/ready and
/admin/electrum sections plus every new error code in its catalog.
Harmonised names and owners across documents (the `resurrected_tx` pause
reason, the reconciler as the one that watches failed rows, the full
`winner_drawn` payload, a reason for an unanswered pre-payout pool check,
one corroboration-failure row per hash instead of one per tick).
Adds B-98 (coin selection stops at the first covering UTXO and refuses a
payable bet), tightens RND-40 to check both 1 and 400 participants and to
guard PUT /admin/config, and gives a stalled `closing` round a signal
(TX-29, closing_stalled).
Records three decisions confirmed on 2026-09-29: the draw's server_seed is
stored Fernet-encrypted until revealed (SEC-56, DAT-64), the chosen abuse
and key-check thresholds (SEC-49, SEC-54, SEC-55), and a broadcast
rejection is verified against every server before any coin is released,
so matching "already known" text is a shortcut, not a safety condition
(TX-04).
Spec only; no code changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1004 lines
58 KiB
Markdown
1004 lines
58 KiB
Markdown
# 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<br/>(e stdout, B-92)"]
|
||
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`, che sta in `.env` ed è obbligatorio: il compose lo legge
|
||
come `${SITE_ADDRESS:?}` e si rifiuta di partire se manca. `localhost`
|
||
produce un certificato autofirmato dalla CA interna di Caddy, un dominio un
|
||
certificato Let's Encrypt. Non esiste un modo supportato di lanciare
|
||
`uvicorn` fuori da Docker.
|
||
*Codice:* `docker-compose.yml` · `Caddyfile` ·
|
||
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
|
||
*Stato:* da implementare (B-93) · *Oggi:* `SITE_ADDRESS` si passa sul
|
||
comando e non impostato vale `localhost`, quindi un `docker compose up`
|
||
senza variabile riporta un server di produzione su `localhost`.
|
||
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
|
||
`--workers`), in ascolto su `0.0.0.0:8123`, con `--proxy-headers` e
|
||
`--forwarded-allow-ips` limitato all'indirizzo di Caddy: la rete di compose
|
||
ha una sottorete fissa e Caddy un indirizzo IPv4 fisso in essa. Così il log
|
||
di accesso registra l'IP del client invece di quello di Caddy. Tutto lo
|
||
stato in memoria descritto in
|
||
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
|
||
questo.
|
||
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
|
||
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
|
||
proxy e il log di accesso registra l'IP di Caddy.
|
||
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
|
||
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
|
||
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
|
||
`exec`. Le migrazioni non si lanciano mai a mano. Il processo gira come
|
||
utente non root, su un'immagine di base fissata per digest e con dipendenze
|
||
installate da un file di lock con gli hash
|
||
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
|
||
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
|
||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
|
||
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
|
||
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
|
||
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
|
||
443. Su questo si basa la lettura dell'IP del client (vedi
|
||
[sicurezza.md](sicurezza.md)). Caddy non comprime `/rounds/stream`, perché
|
||
la compressione accumulerebbe gli eventi SSE invece di consegnarli subito.
|
||
*Codice:* `docker-compose.yml` · `Caddyfile` ·
|
||
*Test:* `tests/unit/test_caddyfile_security_headers.py`
|
||
- **[ARC-05]** I dati persistenti stanno in `./data/` sull'host, montata nel
|
||
container: `./data/db` → `/app/db_data`, `./data/keys` → `/app/key_data`,
|
||
`./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e
|
||
`MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili
|
||
arrivano da `.env` (`env_file`). Entrambi i container hanno
|
||
`restart: unless-stopped`. La chiave di cifratura della master key è un
|
||
file a parte, `./secrets/xprv_encryption.key` (permessi `0600`, fuori da
|
||
`./data/` così un backup dei dati non la contiene), montato in sola lettura
|
||
come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da
|
||
`XPRV_ENCRYPTION_KEY_FILE` ([ARC-30]). Le directory di `./data/` hanno
|
||
permessi `0700`, i file di chiave `0600`. Le immagini sono etichettate per
|
||
rilascio, così un rollback sa quale immagine rimettere
|
||
([operativita.md](operativita.md) (B-93)).
|
||
*Codice:* `docker-compose.yml`
|
||
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
|
||
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
|
||
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
|
||
|
||
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
||
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
|
||
(timeout 5s, 3 tentativi, `start_period` 60s). `/health/ready` risponde 200
|
||
solo se la connessione Electrum è attiva, l'ultimo header è recente e lo
|
||
scheduler ha battuto da poco; le soglie sono di
|
||
[api.md](api.md#servizio) (B-92). Lo scheduler aggiorna il battito a ogni
|
||
giro **e** a ogni iterazione dell'attesa del blocco di estrazione, perché
|
||
quell'attesa gira dentro il giro ([ARC-27]) e dura più blocchi. Docker non
|
||
riavvia un container `unhealthy`: lo stato serve al monitoraggio
|
||
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
|
||
resta affidato a `restart: unless-stopped` quando il processo esce.
|
||
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
|
||
`/health`, che risponde sempre `ok` ([ARC-17]).
|
||
|
||
### Database
|
||
|
||
- **[ARC-06]** Il database è SQLite, usato tramite SQLAlchemy 2 in modalità
|
||
asincrona (`create_async_engine` su `aiosqlite`). Una sola `engine` e una
|
||
sola fabbrica di sessioni (`AsyncSessionLocal`, con
|
||
`expire_on_commit=False`) servono sia le richieste HTTP (dipendenza
|
||
`get_session`) sia le attività in background, che aprono ciascuna le
|
||
proprie sessioni.
|
||
*Codice:* `app/db/base.py` · `app/db/session.py:get_session`
|
||
- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati
|
||
`journal_mode=WAL`, `synchronous=FULL` e `busy_timeout=5000` ms: in WAL
|
||
lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il
|
||
database occupato aspetta fino a 5 secondi invece di fallire subito, e
|
||
`FULL` fa sì che un commit riuscito sopravviva a una mancanza di corrente
|
||
(con `NORMAL` si potevano perdere gli ultimi commit, per esempio una
|
||
registrazione, riusandone poi l'indice di derivazione). Ogni transazione che
|
||
sposta denaro si apre con `BEGIN IMMEDIATE`, così le letture su cui decide
|
||
sono isolate e l'attesa del lock avviene all'inizio, non alla prima
|
||
scrittura; un `database is locked` dopo un invio è gestito esplicitamente
|
||
(la riga resta al riconciliatore). Con un dialetto diverso da SQLite le
|
||
pragma non vengono applicate.
|
||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||
*Test:* `tests/unit/test_db_base.py`
|
||
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL`, e pysqlite
|
||
in modalità legacy emette `BEGIN` solo alla prima scrittura, quindi le
|
||
letture precedenti non sono isolate e l'attesa del lock può scattare in
|
||
punti qualsiasi.
|
||
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
|
||
tramite migrazioni Alembic in `migrations/versions/` (generate con
|
||
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
|
||
database da `settings.database_url`. I campi delle tabelle sono descritti
|
||
in [modello-dati.md](modello-dati.md).
|
||
*Codice:* `migrations/env.py` · `app/db/models.py`
|
||
|
||
### Log
|
||
|
||
- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`,
|
||
`uvicorn.access`) finiscono in `logs/app.log` (nel container
|
||
`/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da
|
||
10 MB con 5 file di storico, livello `INFO`, **e anche su stdout**, così
|
||
`docker compose logs` li mostra senza entrare nel volume. La configurazione
|
||
avviene all'import di `app/main.py`, prima di qualunque altro modulo
|
||
applicativo.
|
||
*Codice:* `app/logging_config.py:setup_logging`
|
||
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
|
||
nel file; su console arrivano solo quelli di uvicorn, per la sua
|
||
configurazione predefinita.
|
||
|
||
## Mappa del codice
|
||
|
||
Ogni file sotto `app/`, raggruppato per pacchetto. I file `__init__.py`
|
||
sono vuoti e non sono elencati.
|
||
|
||
### Radice e infrastruttura
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/main.py` | Punto d'ingresso: `lifespan` (validazione segreti, avvio e arresto dei sei task), inclusione dei router, gestore degli errori non previsti, pagine HTML, `/health`, mount dei file statici |
|
||
| `app/config.py` | `Settings` (variabili d'ambiente / `.env`), istanza `settings`, `validate_runtime_secrets`, `ConfigError` |
|
||
| `app/logging_config.py` | `setup_logging`: file di log a rotazione |
|
||
| `app/db/base.py` | `engine`, `AsyncSessionLocal`, pragma SQLite, `Base` dichiarativa |
|
||
| `app/db/session.py` | `get_session`, dipendenza FastAPI che apre una sessione per richiesta |
|
||
| `app/db/models.py` | Tutte le tabelle (`User`, `UtxoEvent`, `Round`, `RoundParticipant`, `RoundConfig`, `PendingTransaction`, `Withdrawal`, `BugReport`, `AuditLog`) e l'indice del round attivo unico — vedi [modello-dati.md](modello-dati.md) |
|
||
| `app/audit/log.py` | `write_audit_log`: aggiunge una riga al registro di audit nella sessione del chiamante (il commit resta al chiamante) — catalogo eventi in [audit-log.md](audit-log.md) |
|
||
|
||
### API HTTP
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/api/errors.py` | `ApiError` (codice + messaggio inglese + parametri), `http_error`, `from_api_error`: il formato strutturato di `detail` — vedi [api.md](api.md) |
|
||
| `app/api/client_ip.py` | `client_ip`: IP reale del chiamante (ultimo elemento di `X-Forwarded-For`), usato da tutti i controlli basati sull'IP |
|
||
| `app/api/timeutil.py` | `isoformat_utc`: serializza le date come UTC esplicito (SQLite le restituisce senza fuso) |
|
||
| `app/api/routes/admin.py` | `/admin/*`: `require_admin` (token condiviso), configurazione, pausa/ripresa, utenti, export WIF, reset password, round, transazioni pendenti, audit log, bug report |
|
||
| `app/api/routes/bets.py` | `POST /bets`: controllo connessione, lock per utente, chiamata a `place_bet` |
|
||
| `app/api/routes/withdrawals.py` | `POST /withdrawals`: stessa struttura, chiama `request_withdrawal` |
|
||
| `app/api/routes/rounds.py` | `GET /rounds/current` e il canale SSE `GET /rounds/stream` |
|
||
| `app/api/routes/users.py` | `GET /users/me`, cambio password, `GET /users/me/last-round-result` |
|
||
| `app/api/routes/qr.py` | `GET /qr/{address}`: PNG del QR, indirizzo validato, rendering memorizzato e fuori dall'event loop |
|
||
| `app/api/routes/bug_reports.py` | `POST /bug-reports`, `GET /bug-reports/mine` |
|
||
|
||
### Autenticazione
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/auth/routes.py` | `/auth/register`, `/auth/login`; notifica al listener il nuovo indirizzo da osservare |
|
||
| `app/auth/security.py` | Argon2 (versioni sincrone e `*_async` su threadpool), creazione e decodifica JWT, `MIN_PASSWORD_LENGTH` |
|
||
| `app/auth/dependencies.py` | `get_current_user`, `get_optional_user` (controllo del claim `tv`) |
|
||
| `app/auth/rate_limit.py` | `RateLimiter`, `RollingQuota`, `AuthRateLimiters` (tenuti su `app.state`) — vedi [sicurezza.md](sicurezza.md) |
|
||
|
||
### Wallet e chiavi
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` |
|
||
| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv |
|
||
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) 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) |
|
||
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
|
||
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
|
||
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79), UTXO sotto `DUST_LIMIT_SATS` esclusi dalla selezione (B-75) |
|
||
|
||
### Rete Electrum
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `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` |
|
||
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
|
||
|
||
### Dominio
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/deposits/service.py` | Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino |
|
||
| `app/deposits/reconcile.py` | `DepositReconciler`: ricontrollo periodico di tutti gli indirizzi |
|
||
| `app/bets/service.py` | `place_bet`, `BetError`, rilascio di una scommessa fallita |
|
||
| `app/bets/confirmation.py` | Gestore di conferma per il tipo `bet` |
|
||
| `app/withdrawals/service.py` | `request_withdrawal`, `WithdrawalError`, rilascio di un prelievo fallito |
|
||
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
|
||
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
|
||
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
|
||
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
|
||
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
|
||
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
|
||
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
|
||
|
||
Flussi descritti in [scommesse-e-prelievi.md](scommesse-e-prelievi.md),
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md).
|
||
|
||
### Transazioni
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/tx/confirmation.py` | `ConfirmationPoller`, `poll_once`, registro dei gestori (`register_handler`) |
|
||
| `app/tx/broadcast.py` | `RbfBumper`, `should_bump`, `bump_fee` |
|
||
| `app/tx/reconcile.py` | `PendingTransactionReconciler`, `reconcile_once`: promozione o abbandono delle righe `building`/`pending` |
|
||
| `app/tx/pending_address.py` | `own_address_for`: indirizzo proprietario degli input di una transazione (utente o pool) |
|
||
| `app/tx/locks.py` | `UserLocks`: lock asincrono per utente |
|
||
|
||
Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||
|
||
### Frontend statici
|
||
|
||
| File | Responsabilità |
|
||
|---|---|
|
||
| `app/static/index.html` | Pagina `/` per i giocatori |
|
||
| `app/static/app.js` | Logica di `/`: autenticazione, pannelli, polling, SSE, rivelazione del risultato |
|
||
| `app/static/style.css` | Stile di `/` (e di `/report-bug`) |
|
||
| `app/static/i18n.js` | Tabella `TRANSLATIONS` in 7 lingue, `t()`, `tOrNull()`, `setLanguage()`, `applyStaticTranslations()` |
|
||
| `app/static/report-bug.html` | Pagina `/report-bug`, con il proprio script inline |
|
||
| `app/static/admin.html` | Pannello `/admin` |
|
||
| `app/static/admin.js` | Logica del pannello: token, sezioni, polling dello stato catena, SSE |
|
||
| `app/static/admin.css` | Stile del pannello |
|
||
| `app/static/guida.html` | Pagina `/guida`, per ora un segnaposto |
|
||
|
||
### Dipendenze tra pacchetti
|
||
|
||
Il grafo riporta gli import tra i pacchetti di `app/` (a livello di pacchetto,
|
||
non di singolo modulo). `db` e `config` sono importati quasi ovunque e sono
|
||
omessi dalle frecce per leggibilità.
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
main["main"] --> routes["api.routes"]
|
||
main --> auth
|
||
main --> tx
|
||
main --> rounds
|
||
main --> electrum
|
||
main --> deposits
|
||
main --> bets
|
||
main --> withdrawals
|
||
|
||
routes --> auth
|
||
routes --> bets
|
||
routes --> withdrawals
|
||
routes --> rounds
|
||
routes --> wallet
|
||
routes --> audit
|
||
routes --> apiutil["api (errors, client_ip, timeutil)"]
|
||
auth --> apiutil
|
||
auth --> wallet
|
||
|
||
bets --> apiutil
|
||
bets --> rounds
|
||
bets --> wallet
|
||
bets --> audit
|
||
bets -- "register_handler" --> tx
|
||
withdrawals --> apiutil
|
||
withdrawals --> rounds
|
||
withdrawals --> wallet
|
||
withdrawals --> audit
|
||
withdrawals -- "register_handler" --> tx
|
||
|
||
rounds --> electrum
|
||
rounds --> wallet
|
||
rounds --> audit
|
||
rounds -- "register_handler" --> tx
|
||
|
||
electrum --> deposits
|
||
electrum -- "draw (header math), events" --> rounds
|
||
deposits --> electrum
|
||
deposits --> wallet
|
||
deposits --> audit
|
||
deposits -- "events" --> rounds
|
||
|
||
tx --> wallet
|
||
tx --> electrum
|
||
tx --> audit
|
||
tx -- "events, config" --> rounds
|
||
```
|
||
|
||
- **[ARC-10]** Il poller delle conferme (`app/tx/confirmation.py`) non importa
|
||
i moduli di dominio: sono `app/bets/confirmation.py`,
|
||
`app/rounds/confirmation.py` e `app/withdrawals/confirmation.py` a
|
||
registrarsi con `register_handler(kind, handler)` al momento dell'import,
|
||
per i tipi `bet`, `payout` e `withdrawal`.
|
||
*Codice:* `app/tx/confirmation.py:register_handler` ·
|
||
`app/bets/confirmation.py` · `app/rounds/confirmation.py` ·
|
||
`app/withdrawals/confirmation.py` ·
|
||
*Test:* `tests/unit/test_confirmation.py`
|
||
- **[ARC-11]** Gli errori di dominio destinati all'utente sono sottoclassi di
|
||
`ApiError` (`BetError`, `WithdrawalError`) sollevate direttamente dai
|
||
servizi, con il codice che il frontend traduce; le route li convertono in
|
||
`HTTPException` scegliendo lo stato HTTP. Per questo `bets` e
|
||
`withdrawals` dipendono da `app/api/errors.py`.
|
||
*Codice:* `app/api/errors.py:ApiError` · `app/bets/service.py:BetError` ·
|
||
`app/withdrawals/service.py:WithdrawalError`
|
||
|
||
## Avvio del processo
|
||
|
||
`app/main.py` fa due cose distinte: **all'import** costruisce l'applicazione
|
||
FastAPI; **all'avvio del server** (il `lifespan`) prepara lo stato condiviso
|
||
e lancia le attività in background.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as uvicorn
|
||
participant M as app/main.py (import)
|
||
participant L as lifespan
|
||
U->>M: import app.main
|
||
M->>M: setup_logging()
|
||
M->>M: import dei gestori di conferma (bet, payout, withdrawal)
|
||
M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
|
||
U->>L: startup
|
||
L->>L: validate_runtime_secrets()
|
||
L->>L: parse_endpoints(...)
|
||
L->>L: quorum minimo (B-84, da implementare)
|
||
L->>L: verifica della master key (B-89, da implementare)
|
||
L->>L: START_PAUSED (B-90, da implementare)
|
||
L->>L: app.state.electrum_listener, app.state.user_locks
|
||
L->>L: create_task ×6
|
||
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
|
||
U->>L: shutdown
|
||
L->>L: chiusura dei flussi SSE (B-93, da implementare)
|
||
L->>L: cancel() dei 6 task (e attesa della fine, B-93), chiusura del client Electrum
|
||
```
|
||
|
||
Il diagramma mostra il comportamento atteso; i passi marcati «da
|
||
implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||
|
||
### All'import
|
||
|
||
- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa
|
||
di `app/main.py`, prima di importare qualunque altro modulo `app.*`, così
|
||
anche i log emessi durante gli import finiscono nel file.
|
||
*Codice:* `app/main.py` · `app/logging_config.py:setup_logging`
|
||
- **[ARC-13]** `app/main.py` importa `app.bets.confirmation`,
|
||
`app.rounds.confirmation` e `app.withdrawals.confirmation` **solo per il
|
||
loro effetto collaterale** (la registrazione del gestore, vedi
|
||
[ARC-10]). Questi import non vanno rimossi: senza gestore, il poller
|
||
segnerebbe comunque la `PendingTransaction` come `confirmed`, ma senza
|
||
aggiornare il partecipante, il round o il prelievo corrispondente.
|
||
*Codice:* `app/main.py` · `app/tx/confirmation.py:poll_once`
|
||
- **[ARC-14]** La documentazione automatica (`/docs`, `/redoc`,
|
||
`/openapi.json`) è disattivata a meno che `ENABLE_API_DOCS` sia vero.
|
||
*Codice:* `app/main.py` · *Test:* `tests/unit/test_docs_disabled.py`
|
||
- **[ARC-15]** I router vengono inclusi in quest'ordine: `auth`, `users`,
|
||
`bets`, `withdrawals`, `admin`, `bug_reports`, `qr`, `rounds`. Gli endpoint
|
||
sono in [api.md](api.md).
|
||
*Codice:* `app/main.py`
|
||
- **[ARC-16]** Qualunque eccezione non gestita produce una risposta 500 con
|
||
lo stesso formato degli errori voluti
|
||
(`{"detail": {"code": "internal_error", "message": "internal server error", "params": {}}}`);
|
||
il dettaglio dell'eccezione va solo nel log, mai nella risposta.
|
||
*Codice:* `app/main.py:log_unhandled_exception` ·
|
||
*Test:* `tests/unit/test_rounds_route.py`
|
||
- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare
|
||
database né rete: dice solo che il processo risponde (*liveness*). Se il
|
||
servizio è davvero in grado di lavorare lo dice `GET /health/ready`
|
||
([ARC-61], [api.md](api.md#servizio) (B-92)).
|
||
*Codice:* `app/main.py:health`
|
||
- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il
|
||
file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` →
|
||
`admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`.
|
||
Per ultimo, l'intera cartella `app/static/` è montata su `/` con
|
||
`StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il
|
||
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…), con
|
||
`Cache-Control: no-cache` (il browser li riconvalida a ogni caricamento).
|
||
I percorsi sono relativi alla directory di lavoro, che nel container è
|
||
`/app`.
|
||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
|
||
`app/main.py:user_guide` · `app/main.py:report_bug_page`
|
||
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
|
||
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
|
||
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
|
||
con una pagina nuova.
|
||
|
||
### Nel lifespan
|
||
|
||
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
|
||
l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32
|
||
caratteri o la chiave di cifratura della master key è vuota (il file
|
||
indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o è vuoto), riportando tutti i
|
||
problemi insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||
resta semplicemente chiuso. Il controllo sta nel lifespan e non in un
|
||
validatore di `Settings`, così import e test non hanno bisogno di segreti
|
||
veri.
|
||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||
*Test:* `tests/unit/test_config.py`
|
||
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
|
||
`XPRV_ENCRYPTION_KEY` non sia vuota.
|
||
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
|
||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
||
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
|
||
elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene
|
||
scritto nel log.
|
||
*Codice:* `app/electrum/client.py:parse_endpoints` ·
|
||
*Test:* `tests/unit/test_electrum_client.py`
|
||
*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.
|
||
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
|
||
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
|
||
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
|
||
alla prima richiesta di registrazione o login che ne ha bisogno.
|
||
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
|
||
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
|
||
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
|
||
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento, in
|
||
quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn
|
||
aspetta le connessioni aperte e Docker lo uccide), ciascun task riceve
|
||
`cancel()` e il lifespan **ne attende la fine**, poi il client Electrum, se
|
||
presente, viene chiuso. Il compose dà al container `stop_grace_period: 30s`
|
||
per completare questa sequenza prima del `SIGKILL`.
|
||
*Codice:* `app/main.py:lifespan` · `app/rounds/events.py:RoundEventBroadcaster` · `docker-compose.yml` ·
|
||
*Test:* `tests/unit/test_code_comments.py`
|
||
*Stato:* da implementare (B-93) · *Oggi:* i task ricevono `cancel()` senza
|
||
attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide
|
||
il container dopo i 10s predefiniti.
|
||
|
||
## Attività in background
|
||
|
||
| Task | Costruito con | Cadenza | Ruolo |
|
||
|---|---|---|---|
|
||
| `ElectrumListener` | `_make_electrum_client`, `AsyncSessionLocal`, endpoint | ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi |
|
||
| `RoundScheduler` | `AsyncSessionLocal`, `listener` | 5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti |
|
||
| `ConfirmationPoller` | `AsyncSessionLocal`, `lambda: listener.client` | 10s | `pending` → `confirmed` e gestore per tipo |
|
||
| `RbfBumper` | `AsyncSessionLocal`, `lambda: listener.client` | 30s | Aumenta la commissione di ciò che è `pending` oltre `rbf_timeout_seconds` |
|
||
| `PendingTransactionReconciler` | `AsyncSessionLocal`, `lambda: listener.client` | subito, poi 120s | Promuove o abbandona le righe `building`/`pending` |
|
||
| `DepositReconciler` | `AsyncSessionLocal`, `listener` | 300s, attende prima del primo giro | `refresh_user` su ogni indirizzo |
|
||
|
||
- **[ARC-23]** **Accesso al client Electrum.** Nessun task tiene un
|
||
riferimento fisso al client: `ConfirmationPoller`, `RbfBumper` e
|
||
`PendingTransactionReconciler` ricevono `lambda: listener.client` e lo
|
||
rileggono a ogni giro; `RoundScheduler` e `DepositReconciler` ricevono il
|
||
listener intero e leggono `listener.client` (il secondo anche per chiamare
|
||
`refresh_user`). Così una riconnessione sostituisce il client sotto tutti.
|
||
Un client non nullo implica che l'altezza della catena sia già nota (vedi
|
||
[connessione-electrum.md](connessione-electrum.md)).
|
||
*Codice:* `app/main.py:lifespan`
|
||
- **[ARC-24]** **Client assente.** Quando `listener.client` è `None` ogni task
|
||
salta il giro e riprova alla cadenza successiva; `DepositReconciler`
|
||
interrompe anche una scansione in corso. Le route che devono trasmettere
|
||
(`POST /bets`, `POST /withdrawals`) rispondono invece `503`
|
||
`network_unavailable`.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||
`app/tx/confirmation.py:ConfirmationPoller.run` ·
|
||
`app/tx/broadcast.py:RbfBumper.run` ·
|
||
`app/tx/reconcile.py:PendingTransactionReconciler.run` ·
|
||
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||
`app/api/routes/bets.py:create_bet` ·
|
||
`app/api/routes/withdrawals.py:create_withdrawal` ·
|
||
*Test:* `tests/unit/test_deposit_reconcile.py`
|
||
- **[ARC-25]** **Errori.** Ogni ciclo intercetta qualunque `Exception` di un
|
||
giro, la scrive nel log con lo stack e riprende alla cadenza successiva;
|
||
solo `CancelledError` (lo spegnimento) esce dal ciclo. All'interno di un
|
||
giro, poller, bumper, riconciliatore e `DepositReconciler` isolano anche il
|
||
singolo elemento: una transazione o un utente che fallisce non ferma gli
|
||
altri. Non esiste un supervisore: un task che terminasse non verrebbe
|
||
riavviato (l'unico caso previsto è il listener senza alcun endpoint, che
|
||
scrive un errore ed esce; `parse_endpoints` include però sempre il server
|
||
primario).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run` ·
|
||
`app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:RbfBumper._tick` ·
|
||
`app/tx/reconcile.py:reconcile_once` ·
|
||
`app/deposits/reconcile.py:DepositReconciler.run` ·
|
||
`app/electrum/listener.py:ElectrumListener.run` ·
|
||
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_reconcile.py` ·
|
||
`tests/unit/test_deposit_reconcile.py`
|
||
- **[ARC-26]** **`ElectrumListener`.** Dopo ogni sessione terminata passa
|
||
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
|
||
scatta solo quando tutti i server hanno fallito nello stesso giro.
|
||
Rotazione, keepalive, validazione e corroborazione sono in
|
||
[connessione-electrum.md](connessione-electrum.md). Tre cambiamenti di
|
||
struttura sono attesi dal piano, con le regole in quel documento:
|
||
- il listener conserva gli ultimi 10 header ricevuti; un salto di più
|
||
blocchi scarica e valida gli header intermedi, e un header che si
|
||
concatena a uno di quelli conservati ma non alla punta è una
|
||
riorganizzazione da seguire, non la fine della sessione (B-74, B-85);
|
||
- una sessione connessa la cui punta non avanza da 360s, mentre un altro
|
||
server ne riporta una più alta, viene chiusa e si passa al server
|
||
successivo (B-86);
|
||
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o
|
||
malformata fa fallire la sola richiesta, non la connessione (B-75).
|
||
|
||
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
|
||
`app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||
`app/electrum/client.py:ElectrumClient.connect` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
*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.
|
||
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
|
||
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
|
||
sia, `drawing` compreso: per un round in `drawing` il giro rientra
|
||
nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla
|
||
riga del round (`draw_height`, `participants_hash`, posizioni dei
|
||
partecipanti), in modo idempotente. L'attesa **resta dentro il giro**:
|
||
finché dura, lo scheduler non fa altro, il che è corretto perché nessun
|
||
altro round può aprirsi finché questo non è `closed`. Un'eccezione durante
|
||
l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato
|
||
salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del
|
||
round in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
|
||
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
|
||
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
|
||
`drawing` per sempre
|
||
([LIM-01](decisioni-e-limiti.md#robustezza-del-ciclo-di-vita)).
|
||
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
|
||
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
|
||
lavorano sulla tabella `pending_transactions` (e, 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
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||
*Codice:* `app/tx/confirmation.py:ConfirmationPoller` ·
|
||
`app/tx/broadcast.py:RbfBumper` ·
|
||
`app/tx/reconcile.py:PendingTransactionReconciler` ·
|
||
*Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_broadcast.py` ·
|
||
`tests/unit/test_reconcile.py`
|
||
- **[ARC-29]** **`DepositReconciler`** attende 300s, poi scorre tutti gli
|
||
utenti e chiama `listener.refresh_user` su ciascuno: lo stesso codice usato
|
||
dalle notifiche, così i due percorsi non possono divergere.
|
||
*Codice:* `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||
*Test:* `tests/unit/test_deposit_reconcile.py`
|
||
|
||
## Configurazione
|
||
|
||
La configurazione è divisa in due posti con regole diverse.
|
||
|
||
- **[ARC-30]** **Segreti e collegamenti infrastrutturali** stanno nelle
|
||
variabili d'ambiente (file `.env`, letto da `Settings`; variabili sconosciute
|
||
ignorate). `settings` è costruito una volta all'import di `app/config.py`:
|
||
una modifica richiede il riavvio del container. I campi sono:
|
||
|
||
| Campo (`Settings`) | Variabile | Predefinito | Uso |
|
||
|---|---|---|---|
|
||
| `database_url` | `DATABASE_URL` | `sqlite+aiosqlite:///./plm_lottery.db` | URL SQLAlchemy (in Docker sovrascritto dal compose) |
|
||
| `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario |
|
||
| `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario |
|
||
| `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario |
|
||
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
|
||
| `electrum_cert_pin` | `ELECTRUM_CERT_PIN` | `""` | SHA-256 del certificato DER del primario; vuoto = non fissato ([connessione-electrum.md](connessione-electrum.md) ELC-03) |
|
||
| `allow_no_quorum` | `ALLOW_NO_QUORUM` | `False` | Permette di partire con meno di 2 server di riserva; solo sviluppo ([ARC-60]) |
|
||
| `xprv_encryption_key_file` | `XPRV_ENCRYPTION_KEY_FILE` | `/run/secrets/xprv_encryption_key` | File con la chiave Fernet della master xprv (obbligatorio, [ARC-19]); sostituisce `XPRV_ENCRYPTION_KEY` |
|
||
| `start_paused` | `START_PAUSED` | `False` | Avvia con la lotteria in pausa, dopo un ripristino ([ARC-60]) |
|
||
| `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) |
|
||
| `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) |
|
||
| `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT |
|
||
| `jwt_expire_minutes` | `JWT_EXPIRE_MINUTES` | `1440` (24h) | Durata del token |
|
||
| `admin_token` | `ADMIN_TOKEN` | `""` | Token del pannello `/admin`; vuoto = pannello chiuso |
|
||
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
|
||
|
||
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
|
||
*Stato:* da implementare (B-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=`.
|
||
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
|
||
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
||
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
||
depositi `deposit_confirmations`, conferme del blocco di estrazione
|
||
`draw_confirmations`, tetto al montepremi `max_round_pool_sats`) stanno
|
||
nell'unica riga della tabella `round_config`, modificabile a caldo da
|
||
`/admin` senza riavvio. I valori iniziali sono i default delle colonne di
|
||
`RoundConfig`, non `app/config.py`. Se la riga manca, viene creata al primo
|
||
accesso con `fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md),
|
||
effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||
*Codice:* `app/rounds/config.py:get_round_config` ·
|
||
`app/db/models.py:RoundConfig`
|
||
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:*
|
||
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il
|
||
motivo della pausa non esistono.
|
||
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
|
||
la ripartizione 70/30, le conferme richieste per le transazioni della
|
||
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
|
||
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
|
||
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). Le conferme
|
||
dei depositi sono invece configurabili ([ARC-31]).
|
||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
|
||
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
|
||
transazione, depositi e coinbase compresi.
|
||
|
||
## Aggiornamenti in tempo reale (SSE)
|
||
|
||
Le pagine si aggiornano da sole in due modi sovrapposti: il polling
|
||
periodico e, in più, un canale **Server-Sent Events** che le avvisa appena
|
||
qualcosa cambia.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph Processo
|
||
P1["round aperto / cambio di stato"] --> BR
|
||
P2["scommessa o prelievo inviato / annullato"] --> BR
|
||
P3["transazione confermata / abbandonata"] --> BR
|
||
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
|
||
P5["nuovo header"] --> BR
|
||
BR["broadcaster.publish()"] --> Q1["coda client 1<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`. Oggi il riconciliatore pubblica per
|
||
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
|
||
pubblicazione solo sui cambiamenti è in B-96;
|
||
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
|
||
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
|
||
`app/deposits/service.py:mark_utxos_spent_externally`,
|
||
`app/deposits/service.py:reinstate_reappeared_utxos`;
|
||
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
|
||
|
||
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
|
||
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
|
||
notifica per ogni riga ricontrollata, anche senza cambiamenti.
|
||
|
||
## Lock per utente
|
||
|
||
- **[ARC-38]** `UserLocks` tiene un `asyncio.Lock` per `user_id` (creato alla
|
||
prima richiesta di quell'utente e mai rimosso). `POST /bets` e
|
||
`POST /withdrawals` eseguono l'intera costruzione e trasmissione dentro
|
||
`app.state.user_locks.acquire(user.id)`: per lo stesso utente una scommessa
|
||
e un prelievo non sono mai in costruzione insieme, perché spendono dallo
|
||
stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi;
|
||
cosa implica è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md). Il
|
||
controllo "client connesso" avviene prima di acquisire il lock.
|
||
*Codice:* `app/tx/locks.py:UserLocks.acquire` ·
|
||
`app/api/routes/bets.py:create_bet` ·
|
||
`app/api/routes/withdrawals.py:create_withdrawal`
|
||
|
||
## Frontend
|
||
|
||
- **[ARC-39]** Le interfacce sono pagine statiche in `app/static/`, servite
|
||
direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano,
|
||
senza passo di build, framework né bundler. Parlano tutte con la stessa API
|
||
JSON; l'unica separazione tra utente e amministratore è `require_admin` sul
|
||
lato server.
|
||
*Codice:* `app/main.py` · `app/static/`
|
||
|
||
### `/` — giocatori
|
||
|
||
- **[ARC-40]** **Polling.** Da non autenticati, la pagina interroga
|
||
`GET /rounds/current` ogni 15s solo per la barra di stato della catena. Da
|
||
autenticati: `refreshRound` ogni 15s, ogni 3s mentre il round è in
|
||
`closing`/`drawing`/`paying_out`; `checkLastRoundResult`
|
||
(`GET /users/me/last-round-result`) ogni 20s; il conto alla rovescia
|
||
locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano
|
||
subito (i timer delle schede in background vengono rallentati dal
|
||
browser).
|
||
*Codice:* `app/static/app.js:startChainOnlyPolling` ·
|
||
`app/static/app.js:scheduleNextRoundPoll` ·
|
||
`app/static/app.js:showDashboard`
|
||
- **[ARC-41]** **Richieste.** Ogni chiamata API di `/` ha un timeout di 15s;
|
||
dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione
|
||
persa". Una risposta `401` con codice `session_expired` riporta al login.
|
||
*Codice:* `app/static/app.js:call` · `app/static/app.js:noteFetchOutcome`
|
||
- **[ARC-42]** **SSE.** La pagina apre un solo `EventSource('/rounds/stream')`
|
||
e reagisce sia agli eventi `update` sia a `open` (che scatta alla prima
|
||
connessione e a ogni riconnessione automatica): da autenticati ricarica
|
||
round, profilo e ultimo risultato, altrimenti solo lo stato della catena.
|
||
Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina
|
||
funziona come prima, solo senza l'avviso immediato.
|
||
*Codice:* `app/static/app.js:connectRoundEvents` ·
|
||
`app/static/app.js:onRoundServerEvent`
|
||
- **[ARC-43]** **Chiavi di `localStorage`** usate da `/`:
|
||
|
||
| Chiave | Contenuto |
|
||
|---|---|
|
||
| `plm_token` | JWT della sessione |
|
||
| `plm_username` | Nome utente mostrato |
|
||
| `plm_address` | Indirizzo di deposito |
|
||
| `plm_persisted_result` | Risultato del round rivelato (`round_id`, `won`, `amount_sats`), per ridisegnarlo dopo un ricaricamento |
|
||
| `plm_lang` | Lingua scelta (condivisa con `/report-bug`) |
|
||
|
||
Il logout svuota `localStorage` tranne `plm_lang`. La rimozione di
|
||
`plm_token` in una scheda riporta al login anche le altre schede aperte
|
||
(evento `storage`). La logica della rivelazione del risultato è in
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||
*Codice:* `app/static/app.js:logout` · `app/static/app.js:persistResult` ·
|
||
`app/static/i18n.js:setLanguage`
|
||
|
||
### `/admin` — operatore
|
||
|
||
- **[ARC-44]** Il pannello chiede il token operatore, lo verifica con
|
||
`GET /admin/config` e lo conserva in `sessionStorage` (`plm_admin_token`,
|
||
quindi si perde chiudendo la scheda); ogni chiamata lo invia
|
||
nell'intestazione `X-Admin-Token`. Non è collegato da `/` né lo collega.
|
||
*Codice:* `app/static/admin.js:callAdmin`
|
||
- **[ARC-45]** Il pannello interroga `GET /rounds/current` ogni 15s per la
|
||
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
|
||
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
|
||
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
|
||
aperte; non hanno un polling periodico proprio, quindi se il canale SSE è
|
||
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
|
||
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
|
||
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
|
||
filtri dell'audit log (B-86, B-92).
|
||
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
|
||
`app/static/admin.js:connectAdminEvents` ·
|
||
`app/static/admin.js:onAdminServerEvent`
|
||
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
|
||
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
|
||
e l'audit log le ultime righe senza filtri.
|
||
|
||
### `/report-bug` e `/guida`
|
||
|
||
- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza
|
||
login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e
|
||
per mostrare i propri report (`GET /bug-reports/mine`). Non usa SSE né
|
||
polling. Il flusso di triage è descritto in [api.md](api.md) e in
|
||
[guida-admin.md](guida-admin.md).
|
||
*Codice:* `app/static/report-bug.html`
|
||
- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida
|
||
completa sarà pubblicata qui a breve").
|
||
*Codice:* `app/static/guida.html`
|
||
|
||
## Internazionalizzazione
|
||
|
||
Riguarda `/` e `/report-bug`. `/admin` e `/guida` sono volutamente solo in
|
||
italiano.
|
||
|
||
- **[ARC-48]** Le lingue supportate sono sette: `en`, `it`, `es`, `fr`, `de`,
|
||
`ru`, `zh`. Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in
|
||
`app/static/i18n.js`, caricato prima dello script della pagina; non ci sono
|
||
file separati né richieste di rete.
|
||
*Codice:* `app/static/i18n.js`
|
||
- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di
|
||
`localStorage.plm_lang` se supportato, le prime due lettere di
|
||
`navigator.language` se supportate, altrimenti `en`. `setLanguage` salva la
|
||
scelta in `plm_lang`, ritraduce la pagina e chiama `onLanguageChange` se la
|
||
pagina la definisce. Su `/` il selettore sta nella barra di stato della
|
||
catena, non nella barra di navigazione, perché quest'ultima è nascosta fino
|
||
al login.
|
||
*Codice:* `app/static/i18n.js:detectDefaultLang` ·
|
||
`app/static/i18n.js:setLanguage` · `app/static/index.html`
|
||
- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n`
|
||
(testo), `data-i18n-html`, `data-i18n-placeholder`, `data-i18n-title`,
|
||
`data-i18n-aria-label`, `data-i18n-alt`, applicati da
|
||
`applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che
|
||
nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da
|
||
`onLanguageChange`. Un elemento usa **uno solo** dei due meccanismi, mai
|
||
entrambi (per esempio `#bet-btn` non ha `data-i18n`, perché la sua
|
||
etichetta contiene il costo configurabile).
|
||
*Codice:* `app/static/i18n.js:applyStaticTranslations` ·
|
||
`app/static/app.js:onLanguageChange` ·
|
||
`app/static/app.js:renderBetButton`
|
||
- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi**.
|
||
`t()` cerca la chiave nella lingua corrente, poi in `en`, e se manca anche
|
||
lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null`,
|
||
così il chiamante può usare un testo di riserva. I parametri si
|
||
sostituiscono nei segnaposto `{nome}`.
|
||
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull`
|
||
- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail`
|
||
strutturato `{code, message, params}` (vedi [api.md](api.md)); la
|
||
traduzione spetta al client. Per un `detail` oggetto il frontend mostra
|
||
`error.<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 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`.
|
||
|
||
## 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`
|