# Decisioni di progetto e limiti noti Questo documento raccoglie due cose che non si ricavano leggendo un singolo file del codice: - **le decisioni di progetto (`DEC`)**: scelte deliberate, con il motivo, le alternative scartate dove sono note e le conseguenze. Chi vuole cambiarne una deve saperlo prima di toccare il codice, perché altri requisiti ne dipendono; - **i limiti noti (`LIM`)**: difetti di copertura o di robustezza accettati consapevolmente, con l'impatto, ciò che oggi li attenua e cosa servirebbe per chiuderli. Un limite noto **non è un bug**: un bug è un comportamento che contraddice la specifica e prende un numero `B-nn` ([storico-bug.md](storico-bug.md)). Se si decide di chiudere un limite, si aggiorna prima la voce `LIM` qui (o la si barra come rimossa) e poi il requisito del documento di dominio, secondo [processo-di-modifica.md](processo-di-modifica.md). Ogni voce rimanda al codice che la incarna. I dettagli di comportamento stanno nei documenti di dominio, a cui si rimanda invece di ripeterli. --- ## Parte 1 — Decisioni di progetto (DEC) Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*, *Alternative scartate* (dove note), *Conseguenze*, e i riferimenti. ### Custodia, chiavi e indirizzi - **[DEC-01]** **La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server.** Una sola master xprv, cifrata con Fernet a riposo; la chiave di cifratura sta in una variabile d'ambiente (`XPRV_ENCRYPTION_KEY`), mai nel database né in git. L'utente non controlla nulla finché non preleva. - *Motivazione:* un'esperienza da «conto» (registrazione con username e password, scommessa con un clic) senza portafoglio lato utente, firme o PSBT da gestire nel browser. - *Alternative scartate:* portafoglio non custodiale con firma lato client. - *Conseguenze:* la compromissione del server (file cifrato + variabile d'ambiente) espone tutti i fondi; da qui l'esportazione WIF accettata ([DEC-39]), l'assenza di recupero password ([DEC-37]) e il modello di minaccia di [sicurezza.md](sicurezza.md). *Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` · *Test:* `tests/unit/test_hd.py` - **[DEC-02]** **Un indirizzo per utente, permanente, per tutti gli usi.** Alla registrazione il server deriva un indirizzo P2WPKH BIP84 (`m/84'/746'/0'/0/`, un indice per utente, assegnato come massimo esistente + 1). Lo stesso indirizzo è indirizzo di deposito, riceve le vincite e riceve il resto di ogni scommessa e prelievo. Non esiste un «indirizzo vincitore» separato. - *Motivazione:* un solo indirizzo da osservare per utente, un solo saldo da ricostruire (`utxo_events`), e le vincite accreditate dal normale percorso dei depositi senza logica dedicata. - *Conseguenze:* nessuna privacy fra depositi, vincite e resti; un prelievo verso il proprio indirizzo è rifiutato (renderebbe indistinguibili destinatario e resto); il pagamento di un round si considera accreditato quando il listener vede la moneta sull'indirizzo del vincitore (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). *Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · `app/rounds/confirmation.py:_on_payout_confirmed` · *Test:* `tests/unit/test_hd.py`, `tests/unit/test_users.py` - **[DEC-03]** **Il montepremi è un unico indirizzo, derivato dalla stessa master key sul ramo `1/0` dell'account** (il ramo `0` è riservato agli utenti). Tutte le scommesse di tutti i round arrivano lì e il pagamento è firmato con quella chiave. - *Motivazione:* nessun segreto aggiuntivo da custodire. - *Conseguenze:* i round condividono lo stesso insieme di monete; i resti di polvere e le monete avanzate si accumulano sull'indirizzo senza consolidamento automatico ([LIM-17]). *Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · *Test:* `tests/unit/test_pending_address.py` - **[DEC-04]** **Accesso alla rete solo via protocollo Electrum**, senza nodo completo né P2P. Una sola connessione attiva serve tutto (accrediti, invii, conferme, altezza della catena). - *Motivazione:* nessun nodo da installare e sincronizzare. - *Conseguenze:* la connessione è il principale punto singolo di guasto e il principale rischio di integrità; da qui le difese di [DEC-07] e [DEC-26] e i limiti [LIM-11], [LIM-12], [LIM-14]. Dettagli in [connessione-electrum.md](connessione-electrum.md). *Codice:* `app/electrum/client.py:ElectrumClient` · `app/electrum/listener.py:ElectrumListener` · `app/main.py:lifespan` - **[DEC-05]** **Solo indirizzi bech32 PLM (`plm1…`) come destinazione.** Un prelievo verso un indirizzo legacy (`P…`) o di un'altra catena (`bc1…`) è rifiutato; lo stesso controllo vale per `fee_address` e per `/qr/{address}`. - *Motivazione:* un indirizzo di un'altra catena si interpreta comunque come witness program valido, quindi la transazione si costruirebbe e i fondi sarebbero irrecuperabili. *Codice:* `app/wallet/address.py:is_valid_plm_address` · `app/withdrawals/service.py:request_withdrawal` · `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_non_plm_address` ### Conferme e fiducia nella rete - **[DEC-06]** **Una conferma per ogni tipo di transazione** (deposito, scommessa, pagamento, prelievo), senza logica di rollback in caso di reorg. Non si introducono soglie diverse (3, 6…) senza una decisione esplicita. - *Motivazione:* blocchi da 120s e importi da lotteria; un round attende già tre blocchi in sequenza (vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)). - *Conseguenze:* rischio di reorg a 1 conferma accettato ([LIM-10]). *Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` · *Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored` - **[DEC-07]** **Ogni decisione che sposta denaro in base a una risposta di rete è corroborata da un quorum degli altri server configurati**: il blocco che semina l'estrazione, una moneta data per spesa all'esterno, un nuovo deposito da accreditare. Si interrogano solo gli *altri* server, mai quello attivo (che è ciò che un attaccante controllerebbe); serve la maggioranza stretta di chi risponde. Senza server di riserva configurati la corroborazione passa sempre; se nessuno risponde, fallisce. - *Motivazione:* un solo server ostile non deve poter scegliere il vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59). I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono il quorum. - *Conseguenze:* una corroborazione fallita **ritarda**, non perde: il deposito è riproposto al refresh successivo, l'estrazione attende un blocco ulteriore. Lista vuota di server di riserva = fiducia nel solo server primario ([LIM-11]). Non tutte le letture sono corroborate ([LIM-12]). *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.corroborate_header` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · *Test:* `tests/unit/test_electrum_listener.py` ### Configurazione e ambiente - **[DEC-08]** **I parametri operativi stanno nel database, i segreti nell'ambiente.** Ogni parametro di business (costo della scommessa, durata e pausa del round, animazione, fee rate, timeout RBF, `fee_address`, pausa) è una colonna dell'unica riga di `round_config`, modificabile a caldo da `/admin`; i valori iniziali sono i default delle colonne di `RoundConfig`, non `app/config.py`. In `.env` restano solo segreti e collegamenti (master key, JWT, host Electrum, token admin, URL del database). - *Motivazione:* regolare la lotteria senza ridistribuire né riavviare; i segreti richiedono comunque un riavvio e non vanno scambiati a caldo. - *Conseguenze:* ogni modifica è validata con limiti espliciti ([DEC-09]) e registrata in audit come `config_updated` con prima/dopo. *Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · `app/config.py:Settings` · `app/api/routes/admin.py:update_config` · *Test:* `tests/unit/test_admin.py::test_admin_reads_and_updates_config`, `tests/unit/test_admin.py::test_config_update_is_audit_logged` - **[DEC-09]** **Le modifiche di configurazione sono validate, non fidate.** Ogni campo numerico ha limiti (ad esempio `fee_rate_sat_vb` fra 1 e `MAX_FEE_RATE_SAT_VB`, `round_duration_seconds` almeno 30) e `fee_address` deve essere un indirizzo PLM valido. `paused` non è modificabile da `PUT /admin/config`. - *Motivazione:* un fee rate a 0 produce transazioni che nessun nodo inoltra, una durata a 0 fa scadere il round appena aperto, un `fee_address` estraneo manda la commissione in uno script irrecuperabile (B-05). *Codice:* `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py::test_config_rejects_unusable_values`, `tests/unit/test_admin.py::test_pause_cannot_be_toggled_through_the_config_endpoint` - **[DEC-10]** **I tempi del round sono fotografati all'apertura.** Durata e pausa successiva vengono copiate sulla riga del round quando si apre; una modifica vale dal round successivo, mai per quello in corso. La pausa fra due round è quella annunciata dal round appena chiuso. - *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e spostava il conto alla rovescia di tutti i client (B-61). - *Conseguenze:* **solo i tempi** sono fotografati. Costo della scommessa, fee rate e `fee_address` sono letti dal vivo ([LIM-16]). *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` · *Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed` - **[DEC-11]** **Il server rifiuta di partire con segreti inadeguati**: `JWT_SECRET` sotto i 32 caratteri o `XPRV_ENCRYPTION_KEY` vuota fermano l'avvio. Un `ADMIN_TOKEN` vuoto invece non è fatale: il pannello resta chiuso a tutti. Il controllo è nel lifespan, non in un validatore di `Settings`. - *Motivazione:* evitare un container che sembra sano e si rompe al primo utente reale; tenerlo fuori da `Settings` permette a import e test di funzionare senza `.env`. - *Alternative scartate:* validatore Pydantic su `Settings` (avrebbe fatto fallire la raccolta dei test su un clone pulito, B-15). *Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_config.py` - **[DEC-12]** **Il server gira solo in Docker, in sviluppo come in produzione**, dietro Caddy (TLS automatico, certificato autofirmato su `localhost`). Il container rifiuta di partire senza il file della master key, applica le migrazioni Alembic all'avvio e si riavvia da solo (`restart: unless-stopped`). Il venv serve solo per test, migrazioni e script della chiave. - *Motivazione:* un solo modo di eseguire il server, identico ovunque. - *Conseguenze:* dopo un crash `closing` e `paying_out` riprendono da soli, `drawing` no ([LIM-01]). Procedure in [operativita.md](operativita.md). *Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` · *Test:* `tests/unit/test_docs_no_bare_uvicorn.py` - **[DEC-13]** **Un solo processo, un solo file SQLite** (modalità WAL con `busy_timeout`). Canale SSE, blocchi per-utente, limitatori di tentativi e cache sono in memoria. - *Motivazione:* semplicità a scala di ~100 utenti concorrenti. - *Alternative scartate:* Redis o un database con lock condivisi, da non introdurre in via speculativa. - *Conseguenze:* nessuna distribuzione su più worker ([LIM-18]). L'unico invariante che non dipende dal processo singolo è «al più un round attivo», garantito da un indice ([DEC-22]). *Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · *Test:* `tests/unit/test_db_base.py` ### Economia del gioco - **[DEC-14]** **Ripartizione fissa 70% al vincitore / 30% alla piattaforma**, con l'arrotondamento a favore delle commissioni. È nel codice, non nella configurazione: cambiarla è una modifica del software. - *Motivazione:* una regola economica di fondo non deve poter cambiare con un clic dal pannello. Un'unica funzione alimenta sia il montepremi mostrato sia il pagamento, così i due non possono divergere. *Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` - **[DEC-15]** **Biglietto a prezzo fisso, una scommessa per utente per round, probabilità uguale per tutti** indipendentemente dall'importo effettivo. Nessun deposito minimo. - *Conseguenze:* l'estrazione sceglie fra indirizzi, non pesa gli importi. Poiché il costo è letto dal vivo, una modifica a metà round crea poste diverse con la stessa probabilità ([LIM-16]). *Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` · *Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round` - **[DEC-16]** **La commissione di rete si toglie dall'importo che si muove**, non si aggiunge sopra: per una scommessa arriva al montepremi il costo meno la commissione, per un prelievo arriva a destinazione l'importo meno la commissione. - *Conseguenze:* il montepremi si calcola sommando gli importi effettivi delle scommesse, non moltiplicando il numero di giocatori per il costo (B-11); il prelievo registra sia l'importo richiesto sia quello inviato. *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_deducts_fee_from_amount_not_change` - **[DEC-17]** **La commissione del pagamento esce dalla quota del vincitore**; il 30% della piattaforma resta intatto. - *Conseguenze:* il montepremi annunciato (il 70% delle scommesse confermate) supera di poco quanto il vincitore riceve davvero; la differenza, la commissione del pagamento, non è nota finché il pagamento non è costruito. *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` - **[DEC-18]** **L'accelerazione RBF la paga chi riceve il resto**: l'utente per scommesse e prelievi, il montepremi per i pagamenti. Le uscite verso la controparte (montepremi, indirizzo esterno, vincitore, `fee_address`) non si toccano mai; si riduce solo il resto del mittente. - *Conseguenze:* una transazione senza resto non si può accelerare ([LIM-02]). *Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_find_change_output` · *Test:* `tests/unit/test_broadcast.py` - **[DEC-19]** **Ogni transazione utente conserva un'uscita di resto di almeno `DUST_LIMIT_SATS` (294 sat)**, così resta accelerabile. Chi preleva tutto sposta il saldo meno 294 sat, che restano sul conto (e la commissione di rete esce, come sempre, dall'importo spostato); una scommessa da un saldo pari al costo è rifiutata con `balance_leaves_no_change`, perché il prezzo fisso non si può ridurre. - *Motivazione:* un prelievo totale produceva una transazione a un'uscita che nessun RBF poteva salvare (B-62). Aggiungere monete non avrebbe aiutato: la transazione le spendeva già tutte. - *Conseguenze:* **il pagamento del round fa eccezione**: un resto del montepremi sotto la soglia va alla commissione e, nel caso normale, il pagamento non ha resto ([LIM-02]). *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused` - **[DEC-20]** **Il prelievo minimo è il costo corrente della scommessa**, senza un campo di configurazione separato. - *Motivazione:* un parametro in meno da tenere coerente. - *Conseguenze:* alzare il costo della scommessa alza anche il prelievo minimo, con effetto immediato. *Codice:* `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_amount_below_minimum` - **[DEC-21]** **Tre limiti distinti sul numero di monete e di giocatori:** | Costante | Valore | Protegge da | |---|---|---| | `MAX_TX_INPUTS` | 50 | una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, `too_many_inputs` | | `MAX_PAYOUT_TX_INPUTS` | 500 | un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine | | `MAX_PARTICIPANTS_PER_ROUND` | 400 | un round impagabile: il 401° giocatore è rifiutato con `round_full` **prima** che si muova denaro | - *Motivazione:* il pagamento spende una moneta del montepremi per scommessa; usare per esso il limite utente rendeva impagabile ogni round oltre ~50 giocatori e fermava la lotteria (B-52). I 100 di differenza fra 400 e 500 lasciano spazio alle monete avanzate da round precedenti. - *Conseguenze:* «ogni round si può pagare» è un invariante verificato all'ingresso, non una scoperta al momento del pagamento. Il conteggio dei partecipanti include quelli non ancora confermati (direzione prudente). *Codice:* `app/wallet/psbt_builder.py:select_utxos` · `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_payout_transaction` · *Test:* `tests/unit/test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`, `tests/unit/test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_stays_well_inside_relay_limits` ### Round ed estrazione - **[DEC-22]** **Al più un round attivo, come invariante del database.** Un indice unico su un'espressione costante, limitato agli stati attivi, fa fallire in modo pulito il secondo inserimento concorrente; chi perde adotta il round del vincitore (al massimo 3 tentativi). I round non si sovrappongono mai: il successivo si apre solo quando il precedente è `closed`. - *Alternative scartate:* rendere lo scheduler l'unico autore dei round (la prima scommessa dopo una pausa non avrebbe potuto aprirne uno, B-09). *Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` · *Test:* `tests/unit/test_rounds_service.py::test_the_database_refuses_a_second_active_round`, `tests/unit/test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round` - **[DEC-23]** **Il limite per scommettere è la scadenza del round stesso, non il suo stato nel database**, e la chiusura «a semaforo giallo» aspetta che ogni scommessa già inviata sia confermata prima di estrarre. L'iscrizione è salvata con un compare-and-set sulla riga del round. - *Motivazione:* lo scheduler controlla ogni 5s, quindi lo stato può restare `open` oltre la scadenza; senza il compare-and-set una scommessa poteva finire nel montepremi ed essere esclusa dall'estrazione (B-53). - *Conseguenze:* l'attesa in `closing` è limitata solo dall'abbandono di una scommessa che non conferma mai ([LIM-03]). Dettagli in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). *Codice:* `app/rounds/service.py:round_accepts_bets` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `tests/unit/test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check` - **[DEC-24]** **L'algoritmo di estrazione è un componente sostituibile, non il disegno definitivo**: seme = hash del primo blocco confermato dopo la chiusura, indice = seme modulo numero di partecipanti confermati, ordinati per orario di invio della scommessa. Non si deve architettare il resto del sistema attorno alla sua forma attuale. - *Motivazione:* semplice e deterministico, pensato per essere rimpiazzato. - *Conseguenze:* vedi [LIM-13] per i limiti di questa forma (entropia dal minatore, verificabilità, pareggi). *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_draw.py` - **[DEC-25]** **Il blocco che semina l'estrazione deve essere successivo alla chiusura e corroborato.** L'attesa del blocco non ha timeout (l'entropia dipende davvero da un blocco futuro) ma è osservabile (`draw_stalled`, `draw_waiting_since`). Un'altezza `0` alla chiusura significa «sconosciuta»: la prima altezza appresa diventa la base e si attende un blocco ancora successivo. - *Motivazione:* un hash già pubblico mentre le scommesse erano aperte renderebbe il vincitore prevedibile (B-63); un header fabbricato sceglierebbe il vincitore (B-28). *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · *Test:* `tests/unit/test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`, `tests/unit/test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration` - **[DEC-26]** **Validazione minima degli header, senza catena completa.** Un header deve rispettare il bersaglio di difficoltà che dichiara e, se avanza di un solo blocco, concatenarsi alla punta attuale; la punta non regredisce mai; un secondo header alla stessa altezza o un header senza `hex` vengono ignorati senza chiudere la sessione. - *Motivazione:* bloccare header inventati senza scaricare e validare la storia delle difficoltà; lasciare al quorum ([DEC-07]) il caso residuo. - *Alternative scartate:* validazione completa della catena degli header (fuori scopo per la v1). - *Conseguenze:* [LIM-14]. *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` · *Test:* `tests/unit/test_electrum_listener.py::test_apply_header_rejects_one_that_does_not_chain_from_the_tip`, `tests/unit/test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height` - **[DEC-27]** **Pausa di cortesia fra un round e l'altro** (`round_cooldown_seconds`, 30s), contata dalla **conferma del pagamento**, non dall'inizio della chiusura, così i giocatori vedono l'esito. *Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · `app/rounds/service.py:open_new_round_if_needed` · *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_during_cooldown` - **[DEC-28]** **La pausa per manutenzione ferma solo il round successivo.** È un'azione esplicita dell'operatore con endpoint propri e registrati in audit (`lottery_paused`, `lottery_resumed`), non un campo di configurazione; il round in corso chiude, estrae e paga normalmente. - *Motivazione:* sospendere a metà un round con denaro nel montepremi creerebbe proprio lo stato bloccato che la pausa vuole evitare. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/rounds/service.py:open_new_round_if_needed` · *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_paused`, `tests/unit/test_rounds_service.py::test_pause_does_not_interrupt_a_round_in_progress`, `tests/unit/test_admin.py::test_admin_can_pause_and_resume_the_lottery` - **[DEC-29]** **Senza `fee_address` non si apre nessun round.** Il campo non ha valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun round si apre (`lottery_configured = false` su `GET /rounds/current`, avviso in `/admin`); un round già in corso invece chiude, estrae e paga anche se l'indirizzo viene cancellato. Qualunque altra condizione che renderebbe un round impagabile va aggiunta nello stesso punto. - *Motivazione:* un round aperto senza `fee_address` raccoglieva scommesse e solo al pagamento scopriva di non poter essere pagato (B-66). *Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` · *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address` - **[DEC-30]** **Il montepremi annunciato è quello che sarà pagato**: il 70% delle sole scommesse confermate, affiancato dai valori «in attesa» che includono le scommesse in volo (valori inclusivi, non differenze). - *Motivazione:* mai annunciare più di quanto l'estrazione e il pagamento considereranno (B-65), senza nascondere al giocatore la propria scommessa appena fatta. *Codice:* `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid` ### Denaro in movimento - **[DEC-31]** **Tutto ciò che spende denaro si scrive prima di inviarlo** (stato `building`, monete già segnate come spese) e si risolve contro la catena dopo; nessuna sessione del database resta aperta durante una chiamata di rete. Un invio rifiutato annulla tutto e risponde **502**. - *Motivazione:* un crash fra invio e salvataggio lasciava monete spese in catena senza traccia (B-08, B-25). Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). *Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py` - **[DEC-32]** **Il pagamento si ritenta da solo, mai due volte**: un tentativo ogni 60s, usando l'ultimo `payout_failed` in audit come orologio; prima di costruire si rifiuta se esiste già un pagamento `building`/`pending` per il round, e si escludono le monete impegnate da pagamenti non risolti. *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:_reserved_payout_outpoints` · *Test:* `tests/unit/test_scheduler.py::test_trigger_payout_skips_when_already_in_flight`, `tests/unit/test_scheduler.py::test_tick_throttles_retry_after_a_recent_payout_failure`, `tests/unit/test_scheduler.py::test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout` - **[DEC-33]** **Scommessa e prelievo si escludono solo tramite il blocco per-utente** sulla *costruzione*: un prelievo è accettato anche mentre una scommessa attende conferma, purché le monete confermate e libere lo coprano. - *Motivazione:* il rischio è che scelgano la stessa moneta, già escluso dal blocco e dal fatto che la selezione salta le monete segnate come spese; congelare il resto del saldo non aggiungerebbe sicurezza (B-70). *Codice:* `app/tx/locks.py:UserLocks` · `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed` - **[DEC-34]** **Saldo spendibile e saldo mostrato sono due numeri distinti.** Si spende solo il confermato (`balance_sats`); si mostra quello che include i resti ancora in volo (`pending_balance_sats`, in ambra finché non conferma). Un prelievo coperto solo dal secondo riceve `balance_pending_confirmation`. - *Motivazione:* la selezione a monete intere sottrae l'intera moneta al saldo confermato già all'invio, molto più di quanto si stia spostando. *Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/wallet/balance.py:recompute_balance` · `app/api/routes/users.py:me` · *Test:* `tests/unit/test_balance.py`, `tests/unit/test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance` - **[DEC-35]** **Aritmetica solo in satoshi interi**, anche per i valori letti dalla rete: l'importo di una moneta spesa si ricava dalla transazione grezza, non dal valore decimale restituito dal server. *Codice:* `app/tx/broadcast.py:_prevout_amount` · `app/wallet/psbt_builder.py:estimate_vsize` · *Test:* `tests/unit/test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls` ### Account, amministrazione e sicurezza - **[DEC-36]** **Password con Argon2, sessioni con JWT HS256 da 24 ore, revocabili.** Il token porta la versione dell'account (`tv`); cambio password e reset admin la incrementano e invalidano tutte le sessioni precedenti. Un token senza `tv` vale come versione 0, così il rilascio non ha disconnesso nessuno. Argon2 gira fuori dall'event loop. - *Motivazione:* un reset per account compromesso deve buttare fuori subito l'attaccante (B-34); Argon2 inline bloccava l'intero processo, attività in background comprese (B-55). *Codice:* `app/auth/security.py:create_access_token` · `app/auth/security.py:decode_access_token` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:verify_password_async` · *Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one` - **[DEC-37]** **Nessun recupero password, solo reset.** Non si raccoglie nessuna email. L'utente autenticato può *cambiare* la password fornendo quella attuale; chi l'ha persa si rivolge all'operatore, che con `POST /admin/users/{id}/reset-password` ne genera una casuale, mostrata una volta sola e registrata in audit (`admin_password_reset`). *Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` · *Test:* `tests/unit/test_users.py::test_change_password_requires_current_password`, `tests/unit/test_admin.py::test_admin_resets_user_password` - **[DEC-38]** **Gli username sono un unico spazio di nomi senza distinzione fra maiuscole e minuscole**, garantito da un indice unico su `lower(username)`; il nome resta salvato come digitato (è ciò che mostrano `/admin` e l'audit). Sono ammessi solo caratteri ASCII `[A-Za-z0-9_.-]`, 3–32. La migrazione si rifiuta di partire se due account esistenti differiscono solo per le maiuscole, invece di sceglierne uno da rinominare: entrambi potrebbero avere fondi. - *Motivazione:* «Bob» e «bob» erano due account che condividevano il contatore dei tentativi e un comodo vettore di impersonazione (B-57). *Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `tests/unit/test_auth.py::test_the_database_itself_rejects_a_case_variant` - **[DEC-39]** **L'esportazione della chiave privata WIF di un utente è voluta, non una vulnerabilità.** Il server possiede già la master key: l'endpoint espone via API solo ciò che un operatore potrebbe comunque ottenere con uno script (ad esempio per recuperare fondi bloccati). Ogni accesso scrive `admin_privkey_accessed`, e quella registrazione non va tolta. *Codice:* `app/api/routes/admin.py:user_privkey` · `app/wallet/hd.py:derive_user_wif` · *Test:* `tests/unit/test_admin.py::test_admin_exports_user_privkey` - **[DEC-40]** **L'amministrazione usa un unico token condiviso** (`X-Admin-Token` confrontato a tempo costante sui byte UTF-8 con `ADMIN_TOKEN`), non un login per operatore. `/admin` mostra una schermata di inserimento del token, **non è collegato da `/`** (né `/` da `/admin`) ed è in italiano, non tradotto. - *Conseguenze:* l'audit registra *cosa* è cambiato, mai *chi* l'ha fatto ([LIM-06]). *Codice:* `app/api/routes/admin.py:require_admin` · `app/main.py:admin_panel` · `app/static/admin.html` · *Test:* `tests/unit/test_admin.py::test_admin_requires_token`, `tests/unit/test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500` - **[DEC-41]** **Limitazione dei tentativi solo su login e registrazione**, in memoria. Il login usa un ritardo esponenziale per username (dopo 5 errori) e per IP (dopo 20, soglia più alta per non punire una rete condivisa); un login riuscito azzera solo il contatore dello username. La registrazione usa invece una **quota** di 5 account per IP all'ora, consumata solo dagli account creati davvero. I contatori sono limitati in numero. - *Motivazione:* indovinare una password e creare account in massa sono due problemi diversi (B-33, B-58); il ritardo punisce i tentativi, la quota limita la produzione. - *Conseguenze:* nessun limite su scommesse, prelievi, admin, cambio password ([LIM-07]). *Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` - **[DEC-42]** **L'IP del client è l'ultimo elemento di `X-Forwarded-For`**, e Caddy sovrascrive l'header con il vero indirizzo del peer. Un solo proxy fidato sta davanti all'applicazione, che non è esposta direttamente. - *Motivazione:* il primo elemento è scelto dal client; leggerlo rendeva aggirabile ogni controllo per IP (B-54). Basta una delle due misure. *Codice:* `app/api/client_ip.py:client_ip` · `Caddyfile` · *Test:* `tests/unit/test_client_ip.py`, `tests/unit/test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer` - **[DEC-43]** **Header di sicurezza nel proxy, con `'unsafe-inline'` accettato**: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy` e una CSP `default-src 'self'` più Google Fonts. Script e stili inline restano permessi perché entrambe le SPA usano `onclick` e `style=""`; rimuoverli è un refactoring a parte. La documentazione automatica dell'API (`/docs`, `/redoc`, `/openapi.json`) è spenta per default (`ENABLE_API_DOCS`). - *Conseguenze:* [LIM-09]. *Codice:* `Caddyfile` · `app/main.py` · `app/config.py:Settings` · *Test:* `tests/unit/test_caddyfile_security_headers.py`, `tests/unit/test_docs_disabled.py` - **[DEC-44]** **`/qr/{address}` è pubblico ma limitato**: nessuna autenticazione (lo usa un ``, che non può portare il token), ma l'indirizzo è validato davvero, la lunghezza è limitata, la resa è in cache (512 voci) e fuori dall'event loop. *Codice:* `app/api/routes/qr.py:address_qr` · *Test:* `tests/unit/test_qr.py` - **[DEC-45]** **Segnalazioni di bug aperte a tutti, triage in admin.** Si può inviare una segnalazione da autenticati (viene attribuita all'utente) o no; il testo va scritto in inglese, e la pagina lo dice nella lingua del visitatore. Gli stati sono `open` → `read` → `resolved`, e ogni cambio reale di stato è registrato in audit. *Codice:* `app/api/routes/bug_reports.py:create_bug_report` · `app/api/routes/admin.py:update_bug_report_status` · *Test:* `tests/unit/test_bug_reports.py` ### Interfacce, API e lingue - **[DEC-46]** **Il canale SSE è un'aggiunta al polling, non un sostituto.** Nessun contenuto e nessuna autenticazione: è solo un «è cambiato qualcosa, ricarica»; la personalizzazione resta negli endpoint REST autenticati. Un unico segnale per tutti (non per utente), una coda da 1 elemento per client così le notifiche ridondanti si fondono, tetto di 500 connessioni globali e 5 per IP (oltre, si chiude la connessione più vecchia dello stesso IP). Le pagine ricaricano anche all'evento `open` di ogni riconnessione. - *Alternative scartate:* canale per utente (servirebbe autenticazione sul flusso e sapere a chi interessa ogni evento); Redis pub/sub (solo quando ci saranno più processi). - *Conseguenze:* se il canale cade o è bloccato, le pagine continuano a funzionare con il solo polling. *Codice:* `app/rounds/events.py:RoundEventBroadcaster` · `app/api/routes/rounds.py:round_stream` · *Test:* `tests/unit/test_round_events.py` - **[DEC-47]** **L'API parla una sola lingua e risponde con codici.** Ogni errore rivolto all'utente ha `detail = {code, message, params}`: `message` è in inglese per chi non usa la dashboard, `code` è ciò che il frontend traduce (`error.`), `params` porta i valori (gli importi come `*_sats`). Anche l'errore 500 generico ha questa forma (`internal_error`) e non espone il testo dell'eccezione. - *Conseguenze:* aggiungere un errore significa dargli un codice e aggiungere `error.` in tutte le 7 lingue (vedi [api.md](api.md)). *Codice:* `app/api/errors.py:ApiError` · `app/api/errors.py:http_error` · `app/main.py:log_unhandled_exception` · *Test:* `tests/unit/test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape` - **[DEC-48]** **Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue** (en, it, es, fr, de, ru, zh) da un'unica tabella piatta, senza build né download. Lingua scelta da `localStorage.plm_lang`, poi dal browser, poi inglese; nessun'altra lingua di riserva oltre l'inglese. Il selettore di `/` sta nella barra di stato della catena, non nella barra di navigazione (nascosta prima del login). Un elemento è tradotto *o* dagli attributi `data-i18n` *o* dal codice con `t()`, mai da entrambi. Tutte le lingue devono avere esattamente le stesse chiavi. - *Conseguenze:* la parità delle chiavi non è verificata da un test ([LIM-21]). *Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:applyStaticTranslations` · `app/static/i18n.js:setLanguage` · `app/static/app.js:onLanguageChange` - **[DEC-49]** **Il pannello `/admin` e la pagina `/guida` non sono tradotti** (sono in italiano, per l'operatore). *Codice:* `app/static/admin.html` · `app/static/guida.html` - **[DEC-50]** **Frontend statici, senza framework né build**: due SPA servite direttamente da FastAPI con `Cache-Control: no-store`, che parlano con la stessa API JSON (nessuna separazione fra API utente e admin oltre al controllo del token). *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js` --- ## Parte 2 — Limiti noti (LIM) Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per chiuderlo*, e il codice coinvolto. ### Quali limiti bloccano il funzionamento non presidiato | Limite | Blocca il non presidiato? | |---|---| | [LIM-01] `drawing` non riprende dopo un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco, e l'ultimo prerequisito dichiarato | | [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | | [LIM-03] Attesa in `closing` fino a 6 ore | **Sì, in caso di congestione** (conseguenza di LIM-02) | | [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** — da fare prima di lasciare il sistema da solo | | [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | | tutti gli altri | No | ### Robustezza del ciclo di vita - **[LIM-01]** **Lo stato `drawing` non riprende dopo un riavvio.** Lo scheduler gestisce `open`, `closing` e `paying_out` a ogni giro; l'attesa del blocco (`_wait_for_next_block`) vive solo dentro la chiamata a `_close_and_draw` che l'ha avviata, con la lista dei partecipanti in memoria. Se il processo si ferma in quel momento, nessuno la riprende. Il terzo audit (2026-08-03) lo ha ribadito come problema **critico**, senza numerarlo perché già elencato qui. - *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro; poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si ferma**. Con `restart: unless-stopped` un crash in quella finestra è esattamente il caso che non si ripara da solo. - *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento `draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su `GET /rounds/current`. Il recupero è manuale (vedi [operativita.md](operativita.md)). - *Per chiuderlo:* salvare sulla riga del round l'altezza di base dell'estrazione al passaggio in `drawing` e, in `_tick`, rientrare in `_wait_for_next_block` per un round `drawing` senza un'attesa in corso, ricostruendo i partecipanti dal database (sono già tutti `confirmed` e ordinabili per `broadcast_at`). *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` - **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB sull'intera dimensione della transazione (circa 141 sat per una transazione con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino al minimo di 294 sat ([DEC-19]) già il primo aumento produce un'uscita sotto soglia, che le regole di inoltro standard rifiutano. **Il pagamento del round, nel caso normale, non ha alcun resto** (spende esattamente il montepremi del round, e un resto sotto soglia va alla commissione), quindi non è accelerabile affatto. - *Impatto:* con la rete congestionata una transazione a 1 sat/vB può restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo ciò che il server **non conosce**, una transazione ferma ma presente in mempool non viene mai abbandonata: una scommessa così blocca il round in `closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper` registra un errore ogni 30s. - *Mitigazione attuale:* resto minimo garantito sulle transazioni utente; tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del pagamento dopo un abbandono. - *Per chiuderlo:* aggiungere ingressi a una transazione da accelerare (dall'indirizzo del mittente), oppure CPFP sul resto; per il pagamento, garantire un'uscita di resto verso il montepremi come per le transazioni utente; in ogni caso rifiutare un aumento che lasci il resto sotto `DUST_LIMIT_SATS`. Il docstring di `bump_fee` cita ancora come esempio «una scommessa senza resto», caso che dopo B-62 non esiste più. *Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once` - **[LIM-03]** **L'attesa in `closing` non ha un limite proprio.** Il round aspetta ogni scommessa `building`/`broadcast`; a porre fine all'attesa di una scommessa che non conferma è solo il riconciliatore, dopo 6 ore dal primo invio (e solo se la transazione è sparita dalla rete, [LIM-02]). - *Impatto:* un round può restare chiuso ma non estratto per ore; nessun nuovo round si apre nel frattempo. - *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello «Transazioni pendenti» mostra la scommessa che trattiene il round. - *Per chiuderlo:* una politica esplicita per le scommesse lente (per esempio escluderle e rimborsarle dopo un tempo massimo), che è una modifica della specifica del round, non una correzione. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/tx/reconcile.py:_is_due` - **[LIM-04]** **Prelievo e accelerazione RBF non sono mai stati inviati in una rete reale.** Sono coperti da test unitari, rami di errore e annullamento compresi; il resto del flusso (registrazione, deposito, scommessa, un ciclo completo di round con pagamento) è stato verificato in mainnet. - *Impatto:* un errore di costruzione o di firma specifico della rete reale emergerebbe al primo prelievo di un utente vero. - *Per chiuderlo:* un prelievo e un RBF reali in mainnet, con verifica dell'aritmetica sulla transazione inviata. *Codice:* `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` - **[LIM-05]** **Nessun test di integrazione con una connessione Electrum vera.** `tests/integration/` è vuota; la verifica dal vivo è stata tutta manuale (`scripts/electrum_smoke_test.py`, script ad hoc, transazioni reali). - *Impatto:* le differenze di comportamento fra implementazioni di server Electrum (formati di risposta, errori) emergono solo in produzione. - *Per chiuderlo:* una suite su testnet o regtest, o contro un server registrato. *Codice:* `tests/integration/__init__.py` · `scripts/electrum_smoke_test.py` ### Sicurezza e amministrazione - **[LIM-06]** **Un unico token admin condiviso, senza identità per operatore.** Il registro di audit dice *cosa* è cambiato (per esempio `config_updated` con prima/dopo) ma non *chi*. Il token dà accesso a elenco utenti, esportazione delle chiavi private, reset delle password e storico. Non c'è limite ai tentativi sul token. - *Impatto:* una fuga del token ha il raggio d'azione più ampio del sistema, ed è impossibile attribuire un'azione a una persona. - *Mitigazione attuale:* confronto a tempo costante, pannello chiuso se il token è vuoto, ogni azione sensibile in audit, `/admin` non collegato dalla pagina pubblica. Dettagli in [sicurezza.md](sicurezza.md). - *Per chiuderlo:* account amministrativi individuali (con l'identità scritta in ogni voce di audit) e limitazione dei tentativi sul token. *Codice:* `app/api/routes/admin.py:require_admin` · `app/audit/log.py:write_audit_log` - **[LIM-07]** **Nessuna limitazione di frequenza su scommesse, prelievi, endpoint admin, cambio password e segnalazioni di bug.** Solo login e registrazione sono limitati ([DEC-41]); il canale SSE ha tetti di concorrenza (500 globali, 5 per IP), non di frequenza. - *Impatto:* un utente autenticato può martellare scommesse e prelievi (il blocco per-utente li serializza, non li limita); il cambio password permette di provare password attuali senza ritardi a chi possiede già un token valido; le segnalazioni anonime possono riempire il database. - *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`. *Codice:* `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` - **[LIM-08]** **Nessun registro di sicurezza lato utente.** Login, login falliti e cambio password dell'utente non scrivono voci di audit (le scrive solo il reset da parte dell'admin). - *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia avvenuto l'accesso. - *Per chiuderlo:* eventi di audit per questi casi (vedi [audit-log.md](audit-log.md)). *Codice:* `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` - **[LIM-09]** **Il JWT sta in `localStorage` e la CSP ammette script inline.** Una falla XSS permetterebbe di leggere il token. - *Mitigazione attuale:* CSP `default-src 'self'`, `frame-ancestors 'none'`, escape dei valori controllati dall'utente nelle pagine; sessioni revocabili ([DEC-36]). - *Per chiuderlo:* togliere gestori e stili inline dalle SPA per poter eliminare `'unsafe-inline'`, oppure spostare la sessione in un cookie `HttpOnly`. *Codice:* `Caddyfile` · `app/static/app.js` ### Fiducia nella rete - **[LIM-10]** **Rischio di reorg a 1 conferma accettato, senza rollback.** Un deposito accreditato a 1 conferma può sparire con una riorganizzazione; la punta della catena non regredisce mai e un header concorrente alla stessa altezza è ignorato. - *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene, previo quorum, segnata come spesa all'esterno e tolta dal saldo; se riappare viene ripristinata. Una scommessa costruita su una moneta scomparsa è rifiutata dalla rete e annullata. - *Per chiuderlo:* soglie di conferma più alte (decisione esplicita, [DEC-06]) o una gestione completa dei reorg. *Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header` - **[LIM-11]** **Senza server di riserva configurati la corroborazione passa sempre.** Con `ELECTRUM_FALLBACK_SERVERS` vuoto il solo server primario decide estrazione, spese esterne e accrediti. Anche con server di riserva, la maggioranza è calcolata **su chi risponde**, fra server scelti dall'operatore. - *Impatto:* chi controlla il server primario (o la maggioranza dei server raggiungibili) può scegliere il vincitore o alterare i saldi mostrati. - *Mitigazione attuale:* nessun server risponde → la corroborazione **fallisce** (una rete irraggiungibile non prova nulla). - *Per chiuderlo:* rendere obbligatorio un numero minimo di server di riserva, o un quorum minimo di risposte. *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` - **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal solo server attivo: la conferma di scommesse, pagamenti e prelievi (`poll_once`), la decisione del riconciliatore che una transazione «non esiste più», l'elenco delle monete del montepremi usato per il pagamento, e il ripristino di una moneta prima segnata come spesa all'esterno. - *Impatto:* un server ostile potrebbe far considerare confermata una scommessa che non lo è, o far abbandonare a torto una transazione viva (liberandone le monete e, per un pagamento, azzerando `payout_txid`), lasciando il database in disaccordo con la catena; il ripristino non corroborato può reintegrare nel saldo una moneta davvero spesa. - *Mitigazione attuale:* l'abbandono avviene solo dopo 6 ore per `pending`; un invio basato su monete inesistenti è rifiutato dalla rete. - *Per chiuderlo:* estendere il quorum di [DEC-07] a queste decisioni. Il ripristino in particolare contraddice il principio «entrambe le direzioni richiedono il quorum» ed è un candidato a un `B-nn`. *Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` - **[LIM-13]** **Limiti della forma attuale dell'estrazione.** - *Entropia dal minatore:* chi mina il blocco può scegliere di non pubblicarlo se l'esito non gli conviene, rinunciando alla ricompensa. - *Verificabilità:* l'ordine dei partecipanti è l'orario di invio registrato dal server, un dato non pubblico; un giocatore non può ricalcolare da solo l'esito dai soli dati di catena (il commento di `draw_winner` dice il contrario). - *Pareggi:* due scommesse con lo stesso `broadcast_at` non hanno un criterio secondario di ordinamento. - *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti introduce una distorsione trascurabile. - *Per chiuderlo:* sostituire l'algoritmo ([DEC-24]) con uno che pubblichi l'ordine dei partecipanti (o lo derivi da dati di catena) e un criterio di pareggio totale. *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` - **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà reale.** Un server può dichiarare un bersaglio facile e minare davvero un header a quella difficoltà con hardware modesto; la concatenazione si verifica solo per un avanzamento di un blocco esatto. - *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione ([DEC-07], [DEC-25]). - *Per chiuderlo:* validazione della catena degli header con le regole di ricalcolo della difficoltà di PLM. *Codice:* `app/rounds/draw.py:header_meets_its_own_target` · `app/electrum/listener.py:ElectrumListener._apply_header` - **[LIM-15]** **La sottoscrizione di un nuovo indirizzo è a sforzo singolo.** Se fallisce subito dopo la registrazione, l'indirizzo resta non osservato fino alla riconnessione successiva o al giro di `DepositReconciler` (300s, che attende prima di partire). - *Impatto:* un deposito fatto subito dopo la registrazione può tardare fino a cinque minuti. *Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` · `app/deposits/reconcile.py:DepositReconciler` ### Configurazione ed economia - **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo della scommessa, fee rate e `fee_address` sono letti dal vivo. - *Impatto:* cambiare il costo a metà round fa pagare poste diverse a giocatori con la stessa probabilità, e cambia subito il prelievo minimo; cambiare `fee_address` a metà round devia la commissione del round in corso (la modifica è comunque registrata in audit). - *Per chiuderlo:* fotografare anche questi campi sulla riga del round, come già si fa per durata e pausa ([DEC-10]). *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal` - **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete avanzate e resti si accumulano sull'unico indirizzo del montepremi; il margine fra 400 partecipanti e 500 ingressi ([DEC-21]) si consuma nel tempo. - *Impatto:* raggiunto il limite, il pagamento fallisce con `too_many_inputs` e il round resta in `paying_out`, con un `payout_failed` ogni 60s. - *Per chiuderlo:* un'operazione di consolidamento (manuale o automatica) del montepremi. Procedura in [operativita.md](operativita.md). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction` ### Prodotto e interfacce - **[LIM-18]** **Il sistema presuppone un solo processo.** Canale SSE, blocchi per-utente, limitatori di tentativi, cache dei QR e della chiave dell'account sono in memoria. - *Impatto:* con più worker, due costruzioni dello stesso utente potrebbero scegliere le stesse monete, i limiti per IP si dividerebbero per worker e gli eventi SSE arriverebbero solo ai client dello stesso worker. - *Per chiuderlo:* canale condiviso (Redis pub/sub) e lock su database o Redis. L'invariante del round attivo è già un indice e non rientra qui. *Codice:* `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · `app/auth/rate_limit.py:AuthRateLimiters` - **[LIM-19]** **Nessuna cronologia per l'utente.** L'utente non vede lo storico dei round, delle scommesse, dei depositi o dei prelievi: un prelievo fallito lascia una riga `failed` che l'utente non può mai vedere. Esistono solo `GET /users/me/last-round-result` (l'ultimo round giocato) e `GET /bug-reports/mine`. L'operatore ha invece `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`. - *Per chiuderlo:* endpoint e sezione di cronologia personale. *Codice:* `app/api/routes/users.py:last_round_result` · `app/withdrawals/service.py:request_withdrawal` - **[LIM-20]** **`/guida` è un segnaposto.** Il collegamento funziona ma la pagina dice che la guida arriverà. (`/report-bug` invece è completa e tradotta.) *Codice:* `app/main.py:user_guide` · `app/static/guida.html` - **[LIM-21]** **La parità delle chiavi di traduzione non è verificata da un test.** Oggi le 7 lingue hanno le stesse 151 chiavi, ma nulla lo impone: una chiave mancante ricade sull'inglese e, se manca anche lì, viene mostrata la chiave grezza. - *Per chiuderlo:* un test che confronti gli insiemi di chiavi di `TRANSLATIONS`. *Codice:* `app/static/i18n.js:t` - **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un `detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e così il 503 del canale SSE oltre il tetto; è coerente con un pannello non tradotto, ma non è dichiarato altrove. - *Per chiuderlo:* usare `http_error` anche lì, se il pannello dovrà mai tradurre o distinguere gli errori. *Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream` - **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a differenza degli altri elenchi admin. - *Impatto:* con molti utenti la risposta cresce senza limite. *Codice:* `app/api/routes/admin.py:list_users`