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

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

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

44 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"]
    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 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 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. 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. 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
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) 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
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
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, 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: 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. 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). 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. 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). 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 · 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. 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, 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 (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). 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;
    • 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. 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. 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 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: 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).

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