# 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. Come negli altri documenti, una decisione che il codice non rispecchia ancora porta la riga `*Stato:* da implementare (B-nn) · *Oggi:* …` ([processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)). Un limite che il [piano delle correzioni](piano-correzioni.md) chiuderà porta `*Stato:* da chiudere con B-nn`, e *Per chiuderlo* descrive il comportamento atteso; quando la correzione entra nel codice, il limite si barra come `~~[LIM-nn]~~ Rimosso: chiuso da B-nn`. ### Le decisioni del 2026-09-28 La revisione della specifica del 2026-09-28 ha portato a cinque decisioni, prese con il proprietario, che cambiano il disegno del sistema. Il dettaglio tecnico è in [BUGS.md](../BUGS.md), l'ordine di implementazione in [piano-correzioni.md](piano-correzioni.md). Ciascuna è registrata qui sotto come voce `DEC`; dove sostituisce una decisione esistente ne mantiene l'identificativo: | Decisione | Voce | B-nn | |---|---|---| | Estrazione con commit-reveal | [DEC-24] (sostituisce la forma «hash del blocco»), [DEC-25] | B-73, B-81 | | Nel dubbio non si liberano mai i fondi | [DEC-51] | B-76, B-77, B-78 | | Un indirizzo del montepremi per ogni round | [DEC-03] | B-80 | | 6 conferme per i depositi, 1 per il resto | [DEC-06] | B-82 | | Le riorganizzazioni della catena si contengono | [DEC-52] | B-82, B-85 | --- ## 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 un file montato nel container (`XPRV_ENCRYPTION_KEY_FILE`, permessi `0600`, vedi [architettura.md](architettura.md#configurazione)), 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` *Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`. - **[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]** **Ogni round ha il proprio indirizzo del montepremi** (decisione del 2026-09-28), derivato dalla stessa master key sul ramo `m/84'/746'/0'/2/` e salvato sulla riga del round (`rounds.pool_address`). Le scommesse di un round pagano solo il suo indirizzo e il pagamento spende solo le monete di quell'indirizzo. Il vecchio indirizzo unico `m/84'/746'/0'/1/0` diventa l'**indirizzo di riserva** del montepremi, che riceve la riserva per l'RBF di ogni pagamento ([DEC-17]); i round aperti prima della modifica mantengono quell'indirizzo come montepremi. Un prelievo verso un indirizzo del montepremi, di riserva o delle commissioni è rifiutato. Il ramo `0` resta riservato agli utenti. - *Motivazione:* con un indirizzo unico la cronologia cresce senza limite (e con essa le risposte del server, B-75), il denaro di round diversi e le monete estranee si mescolano, e un backup ripristinato ripete un round contro ciò che il montepremi contiene oggi (B-90). Separare gli indirizzi isola denaro, cronologia e pagamento di ogni round. - *Alternative scartate:* un indirizzo unico con contabilità per round nel database (non limita la cronologia e non isola le monete estranee); consolidamento periodico dell'indirizzo unico (sposta il problema, non lo toglie). - *Conseguenze:* nessun segreto aggiuntivo (tutto deriva dalla master key); una moneta estranea inviata all'indirizzo di un round chiuso resta lì, visibile ma fuori da ogni pagamento; il margine fra partecipanti e ingressi del pagamento ([DEC-21]) non si consuma più nel tempo ([LIM-17]). Regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) e [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo) (B-80). *Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · `app/rounds/service.py:open_new_round_if_needed` · *Test:* `tests/unit/test_pending_address.py` *Stato:* da implementare (B-80) · *Oggi:* un unico indirizzo del montepremi (`1/0`) riceve le scommesse di tutti i round e il pagamento sceglie fra tutte le sue monete. - **[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], [DEC-26] e [DEC-52] 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]** **I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1** (decisione del 2026-09-28). «Deposito» è una moneta arrivata all'indirizzo di un utente da una transazione che la piattaforma non ha costruito; le uscite delle transazioni della piattaforma (resto di scommesse e prelievi, vincite) si accreditano a 1 conferma come le transazioni che le hanno prodotte. Le soglie si cambiano solo con una decisione esplicita. - *Motivazione:* il deposito è **l'unico ingresso che un utente può spendere due volte**: tutto il resto lo firma solo il server. Un utente che scommette con un deposito a 1 conferma e poi, se perde, lo sostituisce con una catena privata fa sparire la propria scommessa dal montepremi mentre il partecipante resta contato, e il round diventa impagabile per tutti (B-82). La potenza di calcolo di PLM (~22 TH/s) si può affittare, quindi una riorganizzazione di 1–2 blocchi è alla portata di un singolo attaccante; 6 conferme la rendono costosa quanto sei blocchi di tutta la rete. È anche la soglia di spendibilità del portafoglio di riferimento ([LIM-27]). - *Alternative scartate:* 1 conferma per tutto (la regola precedente, esposta al doppio speso del deposito); 6 conferme per tutto (allunga ogni round di cinque blocchi per coprire transazioni che l'utente non può sostituire); scommesse ammesse solo da monete a 6 conferme ma depositi mostrati prima (due saldi da spiegare all'utente per lo stesso rischio). - *Conseguenze:* un deposito diventa spendibile dopo 6 blocchi (12 minuti in media, con blocchi da 120s) invece di 1; il rischio residuo (una riorganizzazione più profonda di 6 blocchi) è contenuto da [DEC-52] e accettato in [LIM-24]. Regole in [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito) (B-82). *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` *Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase compresi, si accredita a 1 conferma. - **[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, il ripristino di una moneta, la conferma di una transazione (con prova merkle contro un header che possediamo), l'abbandono di una transazione, le monete del round verificate prima del pagamento. Si interrogano solo gli *altri* server, mai quello attivo (che è ciò che un attaccante controllerebbe); servono **almeno 2 risposte** e la maggioranza stretta di chi risponde. Il server rifiuta di partire con meno di 2 server di riserva, salvo `ALLOW_NO_QUORUM=true` (solo sviluppo). Se rispondono meno di 2 server, la corroborazione fallisce. - *Motivazione:* un solo server ostile non deve poter scegliere il vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59), né far credere confermato un pagamento mai trasmesso (B-83). I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono il quorum. Con 0 o 1 server di riserva la «maggioranza» è una sola voce, o nessuna (B-84). - *Alternative scartate:* quorum facoltativo, che passa sempre con la lista vuota (la regola precedente: il default di `.env.example` spegneva ogni difesa senza avvisare). - *Conseguenze:* una corroborazione fallita **ritarda**, non perde e non libera fondi ([DEC-51]): il deposito è riproposto al refresh successivo, l'estrazione richiede di nuovo lo stesso blocco. Il quorum resta fra server scelti dall'operatore ([LIM-11]). Regole in [connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-83, B-84). *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` · `app/main.py:lifespan` · *Test:* `tests/unit/test_electrum_listener.py` *Stato:* da implementare (B-83, B-84) · *Oggi:* senza server di riserva la corroborazione passa sempre, basta una risposta, e conferme, ripristini e abbandoni si decidono sul solo server attivo. - **[DEC-52]** **Le riorganizzazioni della catena si contengono invece di ignorarle** (decisione del 2026-09-28). Tre misure: - *si rilevano*: il listener tiene gli ultimi 10 header e segue una biforcazione fino al punto comune; ogni riorganizzazione scrive `chain_reorg_detected` con profondità e altezze. Una biforcazione che non trova il punto comune entro 10 header è trattata come un header non valido (fine della sessione e rotazione, [DEC-26]); - *si fermano quelle profonde*: una profondità ≥ 2 mette in pausa la lotteria (`paused_reason = 'reorg'`, [DEC-28]) finché l'operatore non la riprende; - *si paga solo ciò che è in catena*: prima di costruire un pagamento le monete dell'indirizzo del round si verificano con il quorum; i partecipanti la cui scommessa non c'è più diventano `reorged_out` e la ripartizione 70/30 si calcola su ciò che resta. Se manca la scommessa del vincitore, il pagamento è trattenuto (`payout_blocked`) e la lotteria va in pausa. Regole in [connessione-electrum.md](connessione-electrum.md#4-header-validati-non-creduti) e [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) (B-82, B-85). - *Motivazione:* la regola precedente («1 conferma, nessun rollback») presupponeva riorganizzazioni rare e innocue. Con la potenza di PLM in affitto una riorganizzazione è un attacco possibile, e senza queste misure una sola scommessa sparita rende il round impagabile per sempre (B-82), mentre una riorganizzazione di un blocco, normale in qualunque catena, veniva scambiata per un server ostile (B-74). - *Alternative scartate:* ignorare le riorganizzazioni (la regola precedente); un rollback completo (ricalcolare saldi già spesi e ripetere estrazioni già rivelate non è sempre possibile e sposterebbe denaro senza l'operatore); pausa anche per profondità 1 (una riorganizzazione di un blocco è frequente e non tocca i depositi a 6 conferme, [DEC-06]). - *Conseguenze:* un attacco al 51% resta possibile; queste misure lo rendono visibile, fanno sì che non blocchi la lotteria in modo irrecuperabile e limitano il danno a ciò che l'attaccante ha rimosso ([LIM-24]). *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` · *Test:* — *Stato:* da implementare (B-82, B-85) · *Oggi:* nessuna riorganizzazione è rilevata; un header concorrente è ignorato, uno che non si concatena chiude la sessione; il pagamento non verifica le monete del round. ### 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 parametri del round sono fotografati all'apertura.** Durata, pausa successiva, costo della scommessa (`bet_amount_sats`), `fee_address` e `draw_confirmations` vengono copiati 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. Il fee rate resta letto dal vivo: riguarda la singola transazione, non le regole del round. - *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e spostava il conto alla rovescia di tutti i client (B-61); cambiare costo o `fee_address` a metà round creava poste diverse con la stessa probabilità e deviava la commissione del round in corso (B-95). - *Conseguenze:* il prelievo minimo ([DEC-20]) segue il costo corrente della configurazione, non quello del round in corso. Regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#0--apertura) (B-95). *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` *Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]). - **[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). - *Conseguenze:* lo stesso principio («rifiutare di servire piuttosto che rompersi al primo uso») si estende alla master key decifrata e confrontata con gli indirizzi già salvati (B-89) e al quorum minimo di server (B-84); l'ordine dei controlli all'avvio è in [architettura.md](architettura.md#nel-lifespan). *Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_config.py` *Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`, [sicurezza.md](sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file mancante o vuoto. - **[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 ogni stato del round riprende da solo, `drawing` compreso ([DEC-25], B-81). Procedure in [operativita.md](operativita.md). *Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` · *Test:* `tests/unit/test_docs_no_bare_uvicorn.py` *Stato:* da implementare (B-81) · *Oggi:* dopo un crash `closing` e `paying_out` riprendono da soli, `drawing` no ([LIM-01]). - **[DEC-13]** **Un solo processo, un solo file SQLite** (modalità WAL con `busy_timeout`, `synchronous=FULL`, `BEGIN IMMEDIATE` per ogni transazione che sposta denaro, vedi [architettura.md](architettura.md#database), B-88). 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/db/base.py:begin_immediate` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · *Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.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 partecipanti, non pesa gli importi. Il costo è fotografato all'apertura del round ([DEC-10]), così tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto dal vivo, [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; la riserva per l'RBF esce dalla quota della piattaforma.** Ogni pagamento ha un'uscita di **riserva** di `max(DUST_LIMIT_SATS, dimensione stimata × 10 sat/vB)` verso l'indirizzo di riserva del montepremi ([DEC-03]); un'accelerazione riduce quell'uscita. La commissione verso `fee_address` è quindi il 30% meno la riserva. - *Motivazione:* il pagamento spende di norma tutte e sole le monete del round, quindi non ha resto e non si potrebbe mai accelerare: un pagamento fermo in mempool terrebbe il round in `paying_out` per sempre (B-79). La riserva la paga la piattaforma perché è la piattaforma a scegliere il fee rate. - *Alternative scartate:* riserva presa dalla quota del vincitore (il montepremi annunciato scenderebbe ancora); CPFP da un'uscita del vincitore (richiede una seconda transazione e una moneta del vincitore); pagamento senza riserva e abbandono dopo un timeout (vietato da [DEC-51]). - *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. [DEC-29] rifiuta di aprire round con una configurazione in cui un round da un partecipante non coprirebbe commissione del vincitore, riserva e una commissione sopra la soglia di polvere. Regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) (B-79). *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` *Stato:* da implementare (B-79) · *Oggi:* nessuna riserva: il 30% va intero a `fee_address` e, nel caso normale, il pagamento non ha resto. - **[DEC-18]** **L'accelerazione RBF la paga chi riceve il resto**: l'utente per scommesse e prelievi, la piattaforma (tramite la riserva di [DEC-17]) per i pagamenti. Le uscite verso la controparte non si toccano mai. La regola, con i casi in cui il resto non basta, è di [TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta). - *Motivazione:* chi riceve il resto è chi ha scelto di spendere; ridurre l'uscita del destinatario cambierebbe l'importo che la controparte si aspetta (B-17). - *Conseguenze:* ogni transazione deve avere un'uscita che il mittente può ridurre: da qui [DEC-19] per le transazioni utente e la riserva di [DEC-17] per i pagamenti ([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 verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina.** Le regole sono di [BET-14](scommesse-e-prelievi.md#cosa-succede) e [WDR-09](scommesse-e-prelievi.md#prelievo) (resto minimo alla costruzione) e di [TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta) (resto eliminato all'accelerazione, B-87). - *Motivazione:* un prelievo totale produceva una transazione a un'uscita che nessun RBF poteva salvare (B-62); aggiungere monete non avrebbe aiutato, perché la transazione le spendeva già tutte. Il resto minimo di 294 sat però **non basta a restare accelerabile**: il primo aumento minimo (1 sat/vB sull'intera transazione, 141 sat per un ingresso e due uscite) lo porta a 153 sat, sotto la soglia di polvere, e ogni sostituzione successiva viene rifiutata dalle regole di inoltro (B-87). Garantisce l'esistenza dell'uscita, non la capacità di assorbire aumenti. - *Alternative scartate:* un resto minimo più grande (sottrae all'utente un importo che deve comunque poter prelevare, e sposta solo la soglia); aggiungere ingressi alla sostituzione (nel caso che conta, il prelievo totale, non ce ne sono altri). - *Conseguenze:* quando il resto scende sotto soglia, il suo valore va in commissione e la sostituzione è l'ultima possibile per quella transazione; l'utente perde al più 294 sat. **Il pagamento del round** non ha un resto utente: la sua uscita riducibile è la riserva di [DEC-17]. *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` · *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` *Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo inoltra. - **[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). Con un indirizzo per round ([DEC-03], B-80) il pagamento spende solo le monete del round, quindi i 100 di differenza fra 400 e 500 restano un margine fisso invece di consumarsi con le monete avanzate dai round precedenti (come accade oggi con l'indirizzo unico). - *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). Un tetto opzionale al montepremi del round (`max_round_pool_sats`, 0 = disattivato) rifiuta con `round_full` anche le scommesse oltre quel valore (B-95); se e come legarlo al costo di un attacco al 51% è una decisione non ancora presa ([LIM-24]). *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` finisce solo quando ogni scommessa è confermata o dimostrata morta ([DEC-51]); non ha un limite di tempo ([LIM-03]). Dettagli in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura). *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'estrazione è un commit-reveal fra il server e un blocco futuro** (decisione del 2026-09-28). All'apertura del round il server genera un segreto casuale di 32 byte (`server_seed`), lo salva e pubblica solo la sua impronta `server_seed_hash = sha256(server_seed)`. All'ingresso in `drawing` si fissano l'elenco dei partecipanti confermati, ordinato per txid della scommessa crescente, la sua impronta `participants_hash` (sha256 delle righe `txid:vout:indirizzo`) e l'altezza del blocco di estrazione (`draw_height`, [DEC-25]). Il seme è `sha256(server_seed || block_hash || participants_hash)` letto come intero, l'indice del vincitore è seme modulo numero di partecipanti. Dopo il salvataggio del vincitore il server rivela `server_seed` e pubblica la prova completa (`GET /rounds/{id}/proof`), da cui chiunque ricalcola l'esito. L'algoritmo resta un **componente sostituibile**: il resto del sistema non va architettato attorno alla sua forma, ma ogni sostituto deve mantenere le stesse quattro proprietà — impegno pubblicato prima delle scommesse, elenco dei partecipanti fissato prima del blocco, esito ricalcolabile dai dati pubblicati, esito non calcolabile da chi produce o trasmette i blocchi. - *Motivazione:* il vincitore non deve essere calcolabile da chi controlla la produzione dei blocchi: la potenza di calcolo di PLM (~22 TH/s) si può affittare. Un minatore che non conosce `server_seed` non sa chi vincerebbe con il suo blocco e quindi non ha motivo di scartarlo; l'operatore non può cambiare `server_seed` dopo averne pubblicato l'impronta e non conosce il blocco quando si impegna. L'ordine per txid e l'impronta dell'elenco rendono l'esito riproducibile e impediscono di aggiungere o togliere partecipanti dopo aver visto il blocco (B-73). - *Alternative scartate:* - *hash del solo blocco* (la forma precedente): un minatore con biglietti in gioco trattiene i blocchi finché uno lo fa vincere, e il server Electrum attivo sceglie quale blocco reale usare semplicemente non annunciandone uno; - *hash di più blocchi consecutivi*: chi produce l'ultimo blocco della serie può ancora scartarlo e riprovare, quindi resta manipolabile; - *segreti forniti dai giocatori* (commit-reveal fra partecipanti): chi non rivela blocca o condiziona l'estrazione, e servirebbe un'azione in più a ogni giocatore; - *sorgente di casualità esterna* (beacon): una dipendenza di rete in più, di cui fidarsi quanto del server. - *Conseguenze:* l'operatore, che conosce `server_seed`, può calcolare l'esito appena il blocco è noto: una collusione fra operatore e minatore resta possibile ed è accettata ([LIM-25]), perché l'operatore è già fidato come custode ([LIM-26]). `server_seed` non è esposto da nessun endpoint prima della rivelazione, e nel database è cifrato con la chiave della master xprv finché non viene rivelato ([sicurezza.md](sicurezza.md#sec-56) SEC-56): una copia del database da sola non basta a conoscerlo. Regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), endpoint in [api.md](api.md) (B-73). *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/service.py:open_new_round_if_needed` · *Test:* `tests/unit/test_draw.py` *Stato:* da implementare (B-73) · *Oggi:* seme = hash del primo blocco confermato dopo la chiusura, partecipanti ordinati per `broadcast_at` (dato solo del server, senza criterio di pareggio), nessun impegno né prova pubblica. - **[DEC-25]** **Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato.** All'ingresso in `drawing` si salva `draw_height = tip_at_close + 1`, che non cambia più. Si attende che quel blocco abbia `draw_confirmations` conferme (3 per default, fotografato all'apertura, [DEC-10]), poi si chiede a tutti i server l'header *a quell'altezza*: serve l'accordo del quorum ([DEC-07]); in caso di disaccordo si attende e si richiede la stessa altezza, mai un blocco diverso. L'attesa non ha timeout (l'entropia dipende davvero da un blocco futuro) ma è osservabile (`draw_stalled`, `draw_waiting_since`) e riprendibile: `draw_height`, `participants_hash` e la posizione di ogni partecipante sono salvati, e lo scheduler riprende un round in `drawing` dopo un errore o un riavvio con lo stesso esito. Un'altezza `0` alla chiusura significa «sconosciuta»: la prima altezza appresa fa da base. - *Motivazione:* un hash già pubblico mentre le scommesse erano aperte renderebbe il vincitore prevedibile (B-63); un header fabbricato sceglierebbe il vincitore (B-28); un'altezza non fissata lascia al server attivo la scelta di quale blocco reale usare, e passare a «un blocco ulteriore» dopo una corroborazione fallita gli dà un secondo tentativo (B-73). Le conferme sul blocco di estrazione evitano che una riorganizzazione cambi il seme dopo l'estrazione. Uno stato tenuto solo in memoria bloccava il round in `drawing` a ogni eccezione (B-81). - *Alternative scartate:* timeout sull'attesa con ripiego su un altro blocco (riaprirebbe la scelta del blocco). *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · `app/rounds/scheduler.py:RoundScheduler._tick` · *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` *Stato:* da implementare (B-73, B-81) · *Oggi:* si usa il primo blocco oltre `tip_at_close` visto dal server attivo, a 1 conferma; dopo una corroborazione fallita si passa al blocco successivo; `tip_at_close` e i partecipanti vivono solo in memoria e `_tick` salta i round in `drawing`. - **[DEC-26]** **Validazione degli header concatenata, senza ricalcolo della difficoltà.** Un header deve dichiarare un bersaglio non più facile del limite di rete (`powLimit`, `0x00000000ffff…`), rispettare quel bersaglio e concatenarsi alla catena che teniamo: su un avanzamento di più blocchi si scaricano gli header intermedi e si verifica tutta la catena. Il listener tiene gli ultimi 10 header; un header che non si concatena alla punta ma a uno di essi è una riorganizzazione da seguire ([DEC-52]), non un server ostile. Un header senza `hex` è ignorato senza chiudere la sessione. Regole in [connessione-electrum.md](connessione-electrum.md#4-header-validati-non-creduti) (B-74, B-85). - *Motivazione:* il controllo «rispetta il bersaglio che dichiara», da solo, **non è una difesa**: il bersaglio lo sceglie chi produce l'header, e con `bits=0x207fffff` un header passa senza lavoro. Un salto di più blocchi non collegato alla punta permetteva di fissare un'altezza enorme, che non regredisce mai, e congelare ogni estrazione (B-74). Trattare ogni riorganizzazione di un blocco come un server ostile chiudeva la sessione su tutti i server fino al blocco successivo. - *Alternative scartate:* validazione completa della catena con le regole di ricalcolo della difficoltà di PLM (fuori scopo: servirebbe la storia delle difficoltà, e il commit-reveal di [DEC-24] toglie a un header fabbricato il potere di scegliere il vincitore). - *Conseguenze:* un header a difficoltà minima costa ancora poco lavoro ([LIM-14]); a fermare una catena fabbricata sono la concatenazione, il quorum ([DEC-07]) e il fatto che il seme dipende anche da `server_seed`. *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` *Stato:* da implementare (B-74, B-85) · *Oggi:* nessun confronto con `powLimit`, concatenazione verificata solo sull'avanzamento di un blocco esatto, un header concorrente alla stessa altezza ignorato, un header che non si concatena chiude la sessione. - **[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. Le regole sono di [RND-04](ciclo-di-vita-del-round.md#0--apertura) (quando si apre il round successivo) e [RND-28](ciclo-di-vita-del-round.md#4--pagamento) (da quando si conta). - *Motivazione:* non è nel diagramma originale; senza pausa il round successivo si apriva prima che i client avessero mostrato il risultato. *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 come qualunque altro round. - *Motivazione:* sospendere a metà un round con denaro nel montepremi creerebbe proprio lo stato bloccato che la pausa vuole evitare. - *Conseguenze:* la stessa pausa è anche il modo in cui il sistema chiede l'intervento dell'operatore, con un motivo (`paused_reason`): una riorganizzazione profonda ([DEC-52], B-85), un pagamento il cui vincitore non ha più la scommessa in catena (B-82), una transazione abbandonata che conferma in ritardo ([DEC-51], B-78), un avvio con `START_PAUSED=true` dopo un ripristino (B-90). Solo l'operatore la toglie. Anche in questi casi il round in corso prosegue, salvo il pagamento trattenuto di B-82. *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). - *Conseguenze:* nello stesso punto si aggiunge il controllo che un round con un solo partecipante copra commissione del vincitore, riserva per l'RBF e una commissione sopra la soglia di polvere ([DEC-17], B-79). *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` *Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo `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. - *Conseguenze:* se prima del pagamento una scommessa confermata risulta sparita per una riorganizzazione, il partecipante esce dal calcolo (`reorged_out`) e il pagato scende sotto l'annunciato ([DEC-52], B-82). *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. Vale anche per le sostituzioni RBF: la nuova versione si salva come candidata prima dell'invio (B-77). Solo un **rifiuto esplicito** della rete annulla tutto e risponde **502**; un invio dall'esito incerto (timeout, connessione caduta dopo la scrittura) lascia la riga `building` al riconciliatore e risponde **202** ([DEC-51]). - *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/broadcast.py:bump_fee` · `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` *Stato:* da implementare (B-76, B-77) · *Oggi:* qualunque eccezione dell'invio, timeout compreso, annulla tutto con 502; `bump_fee` invia la sostituzione prima di salvarla. - **[DEC-51]** **Nel dubbio non si liberano mai i fondi** (decisione del 2026-09-28). Una riga che sposta denaro (scommessa, prelievo, pagamento) si annulla, liberandone le monete, solo con una **prova**: un rifiuto esplicito della rete all'invio, oppure i suoi ingressi spesi da un'altra transazione confermata (corroborata dal quorum), oppure nessun server del quorum che conosca alcun txid della riga *e* una ritrasmissione dell'ultima versione a tutti i server rifiutata per ingressi mancanti o già spesi. Mai per un timeout, per l'età della riga o per il silenzio di un solo server. Si seguono tutti i txid che una riga ha avuto (ogni versione RBF): la conferma di uno qualsiasi conferma la riga. Le righe `failed` restano sorvegliate per 14 giorni; una conferma tardiva scrive `pending_tx_resurrected` e mette in pausa la lotteria ([DEC-28]). Regole in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione) (B-76, B-77, B-78). - *Motivazione:* liberare le monete di una transazione che poi conferma è l'errore peggiore possibile: la scommessa finisce nel montepremi senza partecipante, il prelievo parte mentre la riga dice `failed`, il pagamento viene ricostruito e il vincitore pagato due volte. Tutti e tre accadevano con un invio andato in timeout (B-76), con la conferma di una versione RBF precedente (B-77) o con un riavvio su un server che non aveva ancora visto la transazione (B-78). - *Alternative scartate:* abbandono a tempo (la regola precedente: 120s per `building`, 6 ore per `pending`), che trasforma un ritardo di rete in una perdita; abbandono sul parere del solo server attivo. - *Conseguenze:* una transazione ferma ma viva non si abbandona mai: la si accelera ([DEC-18]); un round può restare in `closing` o `paying_out` finché la rete non decide ([LIM-02], [LIM-03]). Il denaro in dubbio resta congelato e visibile, mai duplicato. *Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` · *Test:* — *Stato:* da implementare (B-76, B-77, B-78) · *Oggi:* l'abbandono avviene quando la cronologia del solo server attivo non contiene il txid corrente, dopo 120s per `building` e 6 ore per `pending`; si segue solo l'ultimo txid; le righe `failed` non sono più controllate. - **[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 ([LIM-07]). Il cambio password (limitato e registrato come `password_changed`) e le segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo delle credenziali ha una lunghezza massima di 256 caratteri ([sicurezza.md](sicurezza.md#limiti-di-frequenza), B-94). *Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` · *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` *Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di bug non hanno limiti né lunghezze massime, e il cambio password non scrive audit. - **[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, che parlano con la stessa API JSON (nessuna separazione fra API utente e admin oltre al controllo del token). Le pagine HTML hanno `Cache-Control: no-store`, i file JS e CSS `Cache-Control: no-cache` (il browser li riconvalida a ogni caricamento, così un aggiornamento non lascia una pagina nuova con uno script vecchio; [architettura.md](architettura.md#allimport), B-94). *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js` *Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno `Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne hanno. --- ## 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 La revisione del 2026-09-28 ha mostrato che la tabella precedente era incompleta: oltre a `drawing` e all'RBF, bloccavano il funzionamento non presidiato le falle critiche B-73 … B-82 (estrazione manipolabile, risposte oltre 64 KiB, abbandoni su un solo server, pagamento senza riserva, deposito spendibile due volte). La colonna *Dopo il piano* dice cosa resta una volta implementato il [piano delle correzioni](piano-correzioni.md). | Limite | Blocca il non presidiato oggi? | Dopo il piano | |---|---|---| | [LIM-01] `drawing` non riprende dopo un errore o un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco | Chiuso da B-81 | | [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 | | [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([DEC-51]); con B-78 e B-87 finisce quando la rete decide | | [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico | | [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85; resta [LIM-24] | | [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 | | [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] | | [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima | | [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | Invariato | | tutti gli altri | No | — | ### Robustezza del ciclo di vita - **[LIM-01]** **Lo stato `drawing` non riprende dopo un errore o un riavvio.** Lo scheduler gestisce `open`, `closing` e `paying_out` a ogni giro e salta `drawing`; l'attesa del blocco (`_wait_for_next_block`) vive solo dentro la chiamata a `_close_and_draw` che l'ha avviata, con `tip_at_close` e la lista dei partecipanti in memoria. Non serve un riavvio: **qualunque eccezione** durante l'attesa (un `database is locked` scrivendo una riga di audit, un header malformato) la interrompe, e nessuno la riprende. Il terzo audit (2026-08-03) lo ha ribadito come problema **critico**, la revisione del 2026-09-28 lo ha numerato (B-81). - *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)). - *Stato:* da chiudere con B-81. - *Per chiuderlo:* al passaggio in `drawing` si salvano `draw_height`, `participants_hash` e la posizione di estrazione di ogni partecipante; `_tick` rientra nell'attesa per un round in `drawing`, in modo idempotente. Con l'altezza fissa e il seme impegnato di [DEC-24]/[DEC-25], un'estrazione ripresa dà sempre lo stesso vincitore. *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 (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. - *Stato:* da chiudere con B-79 e B-87. - *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che l'accelerazione riduce ([DEC-17], B-79); un aumento che lascerebbe un resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in commissione, e quella sostituzione è l'ultima ([DEC-19], B-87); ogni `RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra moneta» né CPFP; una transazione che ha esaurito resto o riserva e non conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non viene abbandonata ([DEC-51]). 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`. Oggi a porre fine all'attesa di una scommessa che non conferma è solo il riconciliatore, dopo 6 ore dal primo invio se la transazione è sparita dalla rete del server attivo; una scommessa ferma ma presente in mempool trattiene il round senza limite ([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. - *Stato:* da chiudere con B-78 (in parte, per scelta). - *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma (una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non confermerà mai ([DEC-51]), non dopo un tempo fisso; con B-87 la scommessa resta accelerabile fino all'eliminazione del resto. Un **limite di tempo resta escluso per scelta**: escludere e rimborsare una scommessa lenta libererebbe fondi senza prova, che è ciò che [DEC-51] vieta. Resta quindi accettato che una scommessa ferma al tetto del fee rate trattenga il round. *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. - *Stato:* da chiudere con la fase 7 del [piano](piano-correzioni.md#fase-7--prova-su-rete-reale). - *Per chiuderlo:* un prelievo e un RBF reali in mainnet, con verifica dell'aritmetica sulla transazione inviata, dentro la prova con importi piccoli e guasti provocati di [operativita.md](operativita.md#prova-su-rete-reale). *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:* lo strato di test di guasto e di integrazione previsto in [architettura.md](architettura.md#test) (server Electrum simulati che rispondono in ritardo, tagliano la connessione, mandano risposte enormi o catene divergenti), più la prova su rete reale di [operativita.md](operativita.md#prova-su-rete-reale). Nessuna delle falle critiche del 2026-09-28 sarebbe stata trovata dai test attuali. *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. - *Stato:* da chiudere in parte con B-94 (cambio password limitato e registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256 caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint admin restano senza limite di frequenza anche dopo il piano. - *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.** Registrazione, 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. - *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e falliti restano senza voce di audit dopo il piano. - *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` - **[LIM-26]** **La piattaforma è custodiale: gli utenti devono fidarsi dell'operatore.** Tutte le chiavi stanno sul server ([DEC-01]): l'operatore (o chi compromette il server) può spendere ogni fondo, leggere `server_seed` prima della rivelazione e scegliere il vincitore con un minatore ([LIM-25]). Nessuna misura del piano lo cambia. - *Impatto:* il rischio di perdita totale dei fondi depositati dipende dalla sicurezza del server e dall'onestà dell'operatore. - *Mitigazione dopo il piano:* chiave cifrata a riposo con la chiave di cifratura in un file separato (B-91), container non root, verifica della chiave all'avvio (B-89), prova pubblica di ogni estrazione ([DEC-24]), audit di ogni azione sensibile. Modello di minaccia in [sicurezza.md](sicurezza.md#modello-di-fiducia). - *Per chiuderlo:* un disegno non custodiale, scartato in [DEC-01]. *Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` ### Fiducia nella rete - **[LIM-10]** **Riorganizzazioni non gestite, depositi a 1 conferma.** Un deposito accreditato a 1 conferma può sparire con una riorganizzazione; la punta della catena non regredisce mai, un header concorrente alla stessa altezza è ignorato e nessuna riorganizzazione viene rilevata. - *Impatto:* il caso grave è una scommessa **già confermata** che sparisce: un utente scommette con un deposito a 1 conferma e, se perde, sostituisce il deposito con una catena privata. La scommessa non è più in catena, ma il partecipante resta `confirmed` e contato, e il pagamento fallisce per sempre con «monete del montepremi insufficienti», con il denaro di tutti gli altri giocatori bloccato. Anche le uscite coinbase sono accreditate a 1 conferma, mentre maturano a 120. - *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 **non ancora inviata** costruita su una moneta scomparsa è rifiutata dalla rete e annullata; per una scommessa già confermata non esiste alcuna mitigazione (la versione precedente di questa voce diceva il contrario). - *Stato:* da chiudere con B-82 e B-85. - *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a 120 ([DEC-06]); riorganizzazioni rilevate, registrate e, se profonde, con pausa; monete del round verificate prima del pagamento, con i partecipanti spariti esclusi ([DEC-52]). Resta accettato il 51% ([LIM-24]). *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 default di `.env.example`) il solo server primario decide estrazione, spese esterne e accrediti. Anche con server di riserva, la maggioranza è calcolata **su chi risponde**: un solo server di riserva raggiungibile decide da solo. I certificati TLS non sono verificati. - *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). - *Stato:* da chiudere con B-84. - *Per chiuderlo:* almeno 2 server di riserva configurati (altrimenti il server rifiuta di partire, salvo `ALLOW_NO_QUORUM=true` in sviluppo) e almeno 2 risposte per ogni corroborazione ([DEC-07]); pin facoltativo del certificato per endpoint (`host:porta:pin=`). Resta accettato dopo il piano che i server del quorum siano scelti dall'operatore: più server controllati dalla stessa parte contano come voci indipendenti. *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. - *Stato:* da chiudere con B-83 (conferme e ripristini), B-78 (abbandono), B-82 (monete del round prima del pagamento) e B-76 (invio a tutti i server). - *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova merkle valida (`blockchain.transaction.get_merkle`) contro un header che possediamo; il ripristino richiede lo stesso quorum di un accredito; l'abbandono richiede la prova di [DEC-51]; le monete del round si verificano con il quorum prima di costruire il pagamento ([DEC-52]); ogni transazione accettata dal server attivo viene inviata anche a tutti i server di riserva. *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]** **L'estrazione attuale è manipolabile da chi produce o trasmette i blocchi.** - *Minatore:* il seme è il solo hash del blocco, quindi un minatore con biglietti in gioco può trattenere i blocchi finché uno lo fa vincere. Non è un'ipotesi remota: la potenza di calcolo di PLM (~22 TH/s) si può affittare, e scartare qualche blocco costa solo le ricompense perse. - *Server attivo:* può scegliere quale blocco reale usare semplicemente non annunciandone uno; la corroborazione verifica che il blocco esista, non che sia il primo dopo la chiusura, e dopo una corroborazione fallita si passa comunque al blocco successivo. - *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 (il commento di `draw_winner` dice il contrario). - *Pareggi:* due scommesse con lo stesso `broadcast_at` non hanno un criterio secondario di ordinamento, quindi l'estrazione non è riproducibile. - *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti introduce una distorsione inferiore a 400/2²⁵⁶, accettata anche dopo il piano. - *Stato:* da chiudere con B-73. - *Per chiuderlo:* il commit-reveal di [DEC-24] con l'altezza fissa e le conferme di [DEC-25]: seme che dipende da un segreto impegnato all'apertura, partecipanti ordinati per txid e fissati da un'impronta, prova pubblica. Resta accettata la collusione fra operatore e minatore ([LIM-25]). *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.** Oggi il bersaglio non è confrontato con il limite di rete: un server può dichiarare `bits=0x207fffff` e l'header passa **senza alcun lavoro** (la versione precedente di questa voce parlava di «hardware modesto», sottostimando il problema). La concatenazione si verifica solo per un avanzamento di un blocco esatto, quindi un header falso a un'altezza enorme blocca `tip_height`, che non regredisce, e con esso ogni estrazione. - *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione ([DEC-07], [DEC-25]); non protegge dal blocco di `tip_height`. - *Stato:* da chiudere con B-74. - *Per chiuderlo:* bersaglio non più facile di `powLimit` e catena verificata su ogni salto ([DEC-26]). Resta accettato dopo il piano: senza il ricalcolo della difficoltà, un header a difficoltà minima (`powLimit`, in media 2³² hash) costa una frazione di secondo a un miner ASIC e pochi secondi a una GPU, quindi un server ostile può ancora produrre una breve catena falsa concatenata alla nostra. A neutralizzarla sono il quorum sull'header di estrazione ([DEC-25]), il segreto del commit-reveal ([DEC-24]) e il rilevamento delle riorganizzazioni ([DEC-52]); una validazione completa con le regole di ricalcolo della difficoltà di PLM resta fuori scopo. *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` - **[LIM-24]** **Un attacco al 51% su PLM resta possibile: è contenuto, non impedito.** Chi controlla la maggioranza della potenza di calcolo (che per PLM, ~22 TH/s, si può affittare) può riscrivere più di 6 blocchi e annullare depositi già accreditati o scommesse già confermate. Riguarda chiunque usi PLM, non solo la lotteria. - *Impatto:* un deposito accreditato e poi annullato da una riorganizzazione più profonda di `deposit_confirmations` è denaro che la piattaforma ha già contato; può essere stato scommesso o prelevato. - *Mitigazione dopo il piano:* 6 conferme sui depositi ([DEC-06]) rendono l'attacco costoso quanto sei blocchi dell'intera rete; il rilevamento e la pausa sulle riorganizzazioni profonde ([DEC-52]) lo rendono visibile e fermano nuovi round; la verifica delle monete prima del pagamento fa sì che un round colpito paghi ciò che resta invece di bloccarsi. Il denaro sottratto dall'attaccante è perso per la piattaforma. - *Aperto:* nessuna di queste misure lega il costo dell'attacco alla dimensione del montepremi. Il tetto opzionale `max_round_pool_sats` ([DEC-21]) è disattivato per default (0) e, anche impostato, il suo valore non discende da alcun calcolo del costo di noleggiare potenza sufficiente a riscrivere `deposit_confirmations` blocchi: resta a discrezione dell'operatore. Per un montepremi abbastanza grande, quindi, l'attacco può convenire nonostante le mitigazioni sopra. Decisione rimandata: se e come legare `max_round_pool_sats` a una stima di quel costo (o attivarlo per default) non è stato ancora deciso. - *Per chiuderlo:* non è chiudibile dal software della lotteria. *Codice:* `app/deposits/service.py:find_new_credit_candidates` · `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` - **[LIM-25]** **Operatore e minatore insieme possono scegliere il vincitore.** Con il commit-reveal ([DEC-24]) l'operatore conosce `server_seed` e può calcolare l'esito di ogni blocco candidato; un minatore d'accordo con lui può scartare i blocchi sfavorevoli. Lo stesso vale per chi ottiene sia il database sia la chiave di cifratura: il segreto è cifrato a riposo ([sicurezza.md](sicurezza.md#sec-56) SEC-56), quindi il solo database, o un suo backup, non basta. - *Impatto:* un round può essere vinto da un complice. - *Perché è accettato:* l'operatore è già fidato come custode di tutti i fondi ([LIM-26]); chi può leggere il database può già fare di peggio. Il commit-reveal impedisce a chi produce o trasmette i blocchi *da solo* di scegliere il vincitore, che era il rischio da chiudere. - *Per chiuderlo:* un contributo di casualità che l'operatore non conosce (segreti dei giocatori, beacon esterno), scartato in [DEC-24]. *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/service.py:open_new_round_if_needed` - **[LIM-27]** **Le soglie di conferma differiscono da quelle del portafoglio di riferimento.** `PalladiumWallet` considera spendibile un'uscita normale dopo `MinConfirmations = 6` conferme e una coinbase dopo 121 (maturità 120 più un blocco di margine). La piattaforma adotta 6 solo per i depositi ([DEC-06]) e 120 per le coinbase; un prelievo o una vincita, confermati qui a 1 conferma, compaiono nel portafoglio di riferimento dell'utente come non ancora spendibili per altri 5 blocchi. - *Impatto:* un utente che preleva verso `PalladiumWallet` vede i fondi arrivati ma bloccati per altri 5 blocchi (10 minuti in media) rispetto a quanto dice la piattaforma; nessun rischio per i fondi. - *Per chiuderlo:* non serve: è una politica del portafoglio del destinatario, non una regola di consenso. Le guide ([guida-utente.md](guida-utente.md)) possono dirlo. *Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ### 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). - *Stato:* da chiudere con B-95. - *Per chiuderlo:* fotografare costo, `fee_address` e `draw_confirmations` sulla riga del round, come già si fa per durata e pausa ([DEC-10]). Il fee rate resta letto dal vivo per scelta, e il prelievo minimo segue il costo corrente della configurazione ([DEC-20]). *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. - *Stato:* da chiudere con B-80. - *Per chiuderlo:* con un indirizzo per round ([DEC-03]) il pagamento spende solo le monete del proprio round, e il margine non si consuma più. Resta accettato dopo il piano: le riserve per l'RBF ([DEC-17]) e le monete estranee inviate agli indirizzi di round chiusi si accumulano senza consolidamento automatico; recuperarle è un'operazione manuale ([operativita.md](operativita.md#intervento-manuale-sui-fondi)). *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. Il piano del 2026-09-28 **non** lo chiude: un solo processo e un solo file SQLite restano la scelta di [DEC-13], adeguata alla scala prevista; il piano rende solo più affidabile quel processo (B-88, B-93). *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. - *Stato:* da chiudere con il test di parità di [ARC-63](architettura.md#test) (nessun B-nn: non è un difetto del codice). - *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. - *Stato:* da chiudere con B-94. - *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale SSE e gli errori generati dal framework usano lo stesso formato `{code, message, params}` ([DEC-47]). *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. - *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti). *Codice:* `app/api/routes/admin.py:list_users`