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>
799 lines
44 KiB
Markdown
799 lines
44 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"]
|
||
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`
|