Files
plm-lottery/docs/architettura.md
T
davideandClaude Sonnet 5.5 c1b192f6d1 Harden the container: non-root, pinned images and dependencies, the key in a file (B-91, SEC-51, SEC-52, SEC-53; closes B-71, B-72)
- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it
  and closes it to others (works on an older root-owned install with no host sudo);
  files are created 0600 (umask 077)
- python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from
  requirements.lock with --require-hashes
- the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose
  secret), out of every environment variable and of a ./data backup
- setup.md no longer presents a second way to run the server (B-72)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:54:23 +02:00

56 KiB
Raw Blame History

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. Le tabelle del database sono in modello-dati.md, gli endpoint in api.md, le difese di sicurezza in sicurezza.md.

Panoramica del deployment

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 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 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 · requirements.lock · Test: tests/unit/test_container_hardening.py

  • [ARC-04] app è solo exposed 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). 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 (B-93)). Codice: docker-compose.yml Test: tests/unit/test_container_hardening.py Stato: da implementare (B-93), per l'etichetta per rilascio · Oggi: l'immagine di app ha l'etichetta fissa plm-lottery-app:local.

  • [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 (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), e il riavvio resta affidato a restart: unless-stopped quando il processo esce. Codice: docker-compose.yml · app/main.py:health · Test: — Stato: da implementare (B-92) · Oggi: nessun healthcheck; esiste solo /health, che risponde sempre ok ([ARC-17]).

Database

  • [ARC-06] Il database è SQLite, usato tramite SQLAlchemy 2 in modalità asincrona (create_async_engine su aiosqlite). Una sola engine e una sola fabbrica di sessioni (AsyncSessionLocal, con expire_on_commit=False) servono sia le richieste HTTP (dipendenza get_session) sia le attività in background, che aprono ciascuna le proprie sessioni. Codice: app/db/base.py · app/db/session.py:get_session
  • [ARC-07] A ogni nuova connessione SQLite vengono impostati journal_mode=WAL, synchronous=FULL e busy_timeout=5000 ms: in WAL lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il database occupato aspetta fino a 5 secondi invece di fallire subito, e FULL fa sì che un commit riuscito sopravviva a una mancanza di corrente (con NORMAL si potevano perdere gli ultimi commit, per esempio una registrazione, riusandone poi l'indice di derivazione). Ogni transazione che sposta denaro si apre con BEGIN IMMEDIATE, così le letture su cui decide sono isolate e l'attesa del lock avviene all'inizio, non alla prima scrittura; un database is locked dopo un invio è gestito esplicitamente (la riga resta al riconciliatore). Con un dialetto diverso da SQLite le pragma non vengono applicate. Codice: app/db/base.py:_register_sqlite_pragmas · app/db/base.py:begin_immediate · Test: tests/unit/test_db_base.py · tests/unit/test_sqlite_transactions.py
  • [ARC-08] Lo schema è definito in app/db/models.py ed evolve solo tramite migrazioni Alembic in migrations/versions/ (generate con alembic revision --autogenerate); migrations/env.py prende l'URL del database da settings.database_url. I campi delle tabelle sono descritti in modello-dati.md. 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
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

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
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

Wallet e chiavi

File Responsabilità
app/wallet/plm_network.py Parametri della rete PLM per embit (PLM_MAINNET), coin type 746, ACCOUNT_PATH
app/wallet/keystore.py Cifratura/decifratura Fernet della master xprv
app/wallet/hd.py Generazione della master key, derivazione di indirizzi/chiavi utente (0/i), del montepremi di ogni round (2/<round_id>, B-80) e dell'indirizzo di riserva (1/0), derive_round_pool_key che sceglie la chiave che controlla l'indirizzo salvato sul round, export WIF; la chiave d'account decifrata resta in cache nel processo
app/wallet/address.py is_valid_plm_address: checksum bech32 + HRP plm
app/wallet/balance.py recompute_balance (saldo confermato), compute_pending_balance (saldo mostrato) — vedi registrazione-e-depositi.md
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; l'uscita di riserva per l'RBF sul pagamento (payout_bump_reserve, B-79)

Rete Electrum

File Responsabilità
app/electrum/client.py ElectrumClient (JSON-RPC, timeout per richiesta), ElectrumEndpoint, parse_endpoints; limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione. Da implementare: pin del certificato per endpoint (B-84)
app/electrum/listener.py ElectrumListener: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi connessione-electrum.md. 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. Seme commit-reveal e impronta dei partecipanti
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, ciclo-di-vita-del-round.md e 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.

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à.

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.

sequenceDiagram
    participant U as uvicorn
    participant M as app/main.py (import)
    participant L as lifespan
    U->>M: import app.main
    M->>M: setup_logging()
    M->>M: import dei gestori di conferma (bet, payout, withdrawal)
    M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
    U->>L: startup
    L->>L: validate_runtime_secrets()
    L->>L: parse_endpoints(...)
    L->>L: quorum minimo (validate_quorum_servers)
    L->>L: verifica della master key (B-89)
    L->>L: START_PAUSED (B-90)
    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. 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 (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

  • [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

  • [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 (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 (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 (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, B-90). Codice: app/main.py:lifespan · app/config.py:validate_runtime_secrets · app/config.py:validate_quorum_servers · Test: tests/unit/test_electrum_listener.py

  • [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). 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. 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;
    • 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/listener.py:ElectrumListener._check_not_stalled · app/electrum/client.py:ElectrumClient.connect · Test: tests/unit/test_electrum_listener.py, tests/unit/test_electrum_client.py

  • [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. Codice: app/rounds/scheduler.py:RoundScheduler._tick · app/rounds/scheduler.py:RoundScheduler._close_and_draw · app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block · Test: tests/unit/test_scheduler.py, tests/unit/test_draw_proof.py

  • [ARC-28] ConfirmationPoller (10s), RbfBumper (30s) e PendingTransactionReconciler (al primo giro subito, poi ogni 120s) lavorano sulla tabella pending_transactions (e sulla tabella di tutti i txid di ogni riga, pending_transaction_txids); il riconciliatore parte subito perché un crash durante un invio lascia proprio una riga building da risolvere al riavvio. Regole in transazioni-e-riconciliazione.md. 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 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

  • [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, effetti in 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 per le transazioni della piattaforma (1) e per le uscite coinbase (120), i limiti di input e di partecipanti, le cadenze dei task. Si cambiano solo con una modifica al codice (vedi limiti-noti.md). 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

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.

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). 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. 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. 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-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 e in 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); 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 descrive correttamente la convivenza di scommessa e prelievo.

    Chi modifica questi file (compresi i documenti in docs/) deve tenerne conto. Test: tests/unit/test_docs_current.py · tests/unit/test_docs_no_bare_uvicorn.py · tests/unit/test_code_comments.py · tests/unit/test_caddyfile_security_headers.py · tests/unit/test_withdrawals.py

  • [ARC-58] Ciò che la suite non copre oggi: nessun test contro una connessione Electrum reale né contro guasti di rete realistici (ritardi, cadute a metà richiesta, risposte enormi, catene divergenti); il lifespan e l'avvio dei sei task; la route SSE round_stream (è testato solo il broadcaster); UserLocks e la sua acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei frontend; la parità delle chiavi tra le lingue di i18n.js; le migrazioni Alembic su dati esistenti (la catena è eseguita solo su un database vuoto, [ARC-62]; lo schema degli altri test nasce da Base.metadata.create_all). Le verifiche dal vivo sono state manuali (scripts/electrum_smoke_test.py e transazioni reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi limiti-noti.md). La revisione del 2026-09-28 ha osservato che nessuna delle sue falle critiche sarebbe stata trovata da questa suite, che simula tutto: da qui [ARC-62], [ARC-63] e [ARC-64].

  • [ARC-62] Test delle migrazioni. Un test esegue alembic upgrade head su un database vuoto e verifica che esistano gli indici su espressione ix_rounds_single_active e ix_users_username_lower, che l'autogenerate non vede e che batch_alter_table su SQLite elimina ricostruendo la tabella con un solo avviso. Ogni migrazione batch su rounds o users li ricrea esplicitamente. Codice: migrations/env.py · migrations/versions/ · Test: tests/unit/test_migrations.py

  • [ARC-63] Test di parità delle traduzioni. Un test legge TRANSLATIONS da app/static/i18n.js e fallisce se una delle sette lingue ha un insieme di chiavi diverso da quello di en ([ARC-51]). Codice: app/static/i18n.js · Test: — Stato: da implementare (senza B-nn: chiude LIM-21) · 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, che resta obbligatoria prima dell'apertura al pubblico. Codice: tests/integration/__init__.py · Test: — Stato: da implementare (fase 7 del piano) · 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). La scelta è discussa in limiti-noti.md. Codice: app/rounds/events.py:RoundEventBroadcaster · app/tx/locks.py:UserLocks · app/auth/rate_limit.py:AuthRateLimiters · app/main.py:lifespan