92 KiB
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). 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.
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).
Un limite che il piano delle correzioni 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, l'ordine di implementazione in
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, permessi0600, vedi architettura.md), 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.
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.pyStato: da implementare (B-91) · Oggi: la chiave di cifratura è la variabile d'ambienteXPRV_ENCRYPTION_KEY, visibile condocker 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/<indice>, 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).
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 - Motivazione: un solo indirizzo da osservare per utente, un solo saldo da
ricostruire (
-
[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/<round_id>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 unicom/84'/746'/0'/1/0diventa 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 ramo0resta 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 e scommesse-e-prelievi.md (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.pyStato: 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.
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 perfee_addresse 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_confirmationsconferme (6 per default, configurabile inround_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 (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_ignoredStato: 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.examplespegneva 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 (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.pyStato: 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_detectedcon 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_oute 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 e ciclo-di-vita-del-round.md (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. - si rilevano: il listener tiene gli ultimi 10 header e segue una
biforcazione fino al punto comune; ogni riorganizzazione scrive
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 diround_config, modificabile a caldo da/admin; i valori iniziali sono i default delle colonne diRoundConfig, nonapp/config.py. In.envrestano 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_updatedcon 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_vbfra 1 eMAX_FEE_RATE_SAT_VB,round_duration_secondsalmeno 30) efee_addressdeve essere un indirizzo PLM valido.pausednon è modificabile daPUT /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_addressestraneo 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 - Motivazione: un fee rate a 0 produce transazioni che nessun nodo inoltra,
una durata a 0 fa scadere il round appena aperto, un
-
[DEC-10] I parametri del round sono fotografati all'apertura. Durata, pausa successiva, costo della scommessa (
bet_amount_sats),fee_addressedraw_confirmationsvengono 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_addressa 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 (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_closedStato: da implementare (B-95) · Oggi: sono fotografati solo durata e pausa; costo,fee_addresse fee rate sono letti dal vivo ([LIM-16]). - 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
-
[DEC-11] Il server rifiuta di partire con segreti inadeguati:
JWT_SECRETsotto i 32 caratteri oXPRV_ENCRYPTION_KEYvuota fermano l'avvio. UnADMIN_TOKENvuoto invece non è fatale: il pannello resta chiuso a tutti. Il controllo è nel lifespan, non in un validatore diSettings.- Motivazione: evitare un container che sembra sano e si rompe al primo
utente reale; tenerlo fuori da
Settingspermette 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.
Codice:
app/config.py:validate_runtime_secrets·app/main.py:lifespan·app/api/routes/admin.py:require_admin· Test:tests/unit/test_config.py - Motivazione: evitare un container che sembra sano e si rompe al primo
utente reale; tenerlo fuori da
-
[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,
drawingcompreso ([DEC-25], B-81). Procedure in operativita.md.
Codice:
Dockerfile·docker-compose.yml·Caddyfile· Test:tests/unit/test_docs_no_bare_uvicorn.pyStato: da implementare (B-81) · Oggi: dopo un crashclosingepaying_outriprendono da soli,drawingno ([LIM-01]). -
[DEC-13] Un solo processo, un solo file SQLite (modalità WAL con
busy_timeout,synchronous=FULL,BEGIN IMMEDIATEper ogni transazione che sposta denaro, vedi architettura.md, 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/tx/locks.py:UserLocks·app/rounds/events.py:RoundEventBroadcaster· Test:tests/unit/test_db_base.pyStato: da implementare (B-88) · Oggi:synchronous=NORMALeBEGINemesso solo alla prima scrittura (modalità legacy di pysqlite).
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 versofee_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_outper 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 (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_shareStato: da implementare (B-79) · Oggi: nessuna riserva: il 30% va intero afee_addresse, nel caso normale, il pagamento non ha resto. - 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
-
[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.
- 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 e WDR-09 (resto minimo alla costruzione) e di TX-11 (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_refusedStato: da implementare (B-87) · Oggi:bump_feerichiede 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_INPUTS50 una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, too_many_inputsMAX_PAYOUT_TX_INPUTS500 un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine MAX_PARTICIPANTS_PER_ROUND400 un round impagabile: il 401° giocatore è rifiutato con round_fullprima 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 conround_fullanche le scommesse oltre quel valore (B-95).
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
openoltre la scadenza; senza il compare-and-set una scommessa poteva finire nel montepremi ed essere esclusa dall'estrazione (B-53). - Conseguenze: l'attesa in
closingfinisce 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.
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 - Motivazione: lo scheduler controlla ogni 5s, quindi lo stato può restare
-
[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 improntaserver_seed_hash = sha256(server_seed). All'ingresso indrawingsi fissano l'elenco dei partecipanti confermati, ordinato per txid della scommessa crescente, la sua improntaparticipants_hash(sha256 delle righetxid: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 rivelaserver_seede 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_seednon sa chi vincerebbe con il suo blocco e quindi non ha motivo di scartarlo; l'operatore non può cambiareserver_seeddopo 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_seednon è esposto da nessun endpoint prima della rivelazione. Regole in ciclo-di-vita-del-round.md, endpoint in 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.pyStato: da implementare (B-73) · Oggi: seme = hash del primo blocco confermato dopo la chiusura, partecipanti ordinati perbroadcast_at(dato solo del server, senza criterio di pareggio), nessun impegno né prova pubblica. - 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
-
[DEC-25] Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato. All'ingresso in
drawingsi salvadraw_height = tip_at_close + 1, che non cambia più. Si attende che quel blocco abbiadraw_confirmationsconferme (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_hashe la posizione di ogni partecipante sono salvati, e lo scheduler riprende un round indrawingdopo un errore o un riavvio con lo stesso esito. Un'altezza0alla 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
drawinga 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_corroborationStato: da implementare (B-73, B-81) · Oggi: si usa il primo blocco oltretip_at_closevisto dal server attivo, a 1 conferma; dopo una corroborazione fallita si passa al blocco successivo;tip_at_closee i partecipanti vivono solo in memoria e_ticksalta i round indrawing. - 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
-
[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 senzahexè ignorato senza chiudere la sessione. Regole in connessione-electrum.md (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=0x207fffffun 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_heightStato: da implementare (B-74, B-85) · Oggi: nessun confronto conpowLimit, 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. - Motivazione: il controllo «rispetta il bersaglio che dichiara», da solo,
non è una difesa: il bersaglio lo sceglie chi produce l'header, e con
-
[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 (quando si apre il round successivo) e RND-28 (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 conSTART_PAUSED=truedopo 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_addressnon si apre nessun round. Il campo non ha valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun round si apre (lottery_configured = falsesuGET /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_addressraccoglieva 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_addressStato: da implementare (B-79) · Oggi:rounds_can_opencontrolla solofee_address. - Motivazione: un round aperto senza
-
[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 rigabuildingal 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.
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.pyStato: da implementare (B-76, B-77) · Oggi: qualunque eccezione dell'invio, timeout compreso, annulla tutto con 502;bump_feeinvia 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
failedrestano sorvegliate per 14 giorni; una conferma tardiva scrivepending_tx_resurrectede mette in pausa la lotteria ([DEC-28]). Regole in transazioni-e-riconciliazione.md (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 perpending), 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
closingopaying_outfinché 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 perbuildinge 6 ore perpending; si segue solo l'ultimo txid; le righefailednon sono più controllate. - 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
-
[DEC-32] Il pagamento si ritenta da solo, mai due volte: un tentativo ogni 60s, usando l'ultimo
payout_failedin audit come orologio; prima di costruire si rifiuta se esiste già un pagamentobuilding/pendingper 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 ricevebalance_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 senzatvvale 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-passwordne 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/admine 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-Tokenconfrontato a tempo costante sui byte UTF-8 conADMIN_TOKEN), non un login per operatore./adminmostra 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, 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.pyStato: 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-Policye una CSPdefault-src 'self'più Google Fonts. Script e stili inline restano permessi perché entrambe le SPA usanoonclickestyle=""; 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<img>, 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
opendi 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.<code>),paramsporta 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.<code>in tutte le 7 lingue (vedi 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 - Conseguenze: aggiungere un errore significa dargli un codice e
aggiungere
-
[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 dalocalStorage.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 attributidata-i18no dal codice cont(), 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
/admine la pagina/guidanon 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 CSSCache-Control: no-cache(il browser li riconvalida a ogni caricamento, così un aggiornamento non lascia una pagina nuova con uno script vecchio; architettura.md, B-94).Codice:
app/main.py:index_page·app/main.py:admin_panel·app/static/app.js·app/static/admin.jsStato: da implementare (B-94) · Oggi: solo le quattro pagine HTML hannoCache-Control(no-store); JS e CSS, serviti daStaticFiles, 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.
| 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) 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
drawingnon riprende dopo un errore o un riavvio. Lo scheduler gestisceopen,closingepaying_outa ogni giro e saltadrawing; l'attesa del blocco (_wait_for_next_block) vive solo dentro la chiamata a_close_and_drawche l'ha avviata, contip_at_closee la lista dei partecipanti in memoria. Non serve un riavvio: qualunque eccezione durante l'attesa (undatabase is lockedscrivendo 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
drawingper sempre con il montepremi dentro; poiché un nuovo round si apre solo a round chiuso, l'intera lotteria si ferma. Conrestart: unless-stoppedun crash in quella finestra è esattamente il caso che non si ripara da solo. - Mitigazione attuale: l'attesa è osservabile — log periodici, evento
draw_stalleddopo 360s (e poi a ogni intervallo),draw_waiting_sincesuGET /rounds/current. Il recupero è manuale (vedi operativita.md). - Stato: da chiudere con B-81.
- Per chiuderlo: al passaggio in
drawingsi salvanodraw_height,participants_hashe la posizione di estrazione di ogni partecipante;_tickrientra nell'attesa per un round indrawing, 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 - Impatto: il round resta in
-
[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_feesollevaRbfError. Inoltrebump_feenon 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 inpaying_out.RbfBumperregistra 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_SATSelimina il resto e ne versa il valore in commissione, e quella sostituzione è l'ultima ([DEC-19], B-87); ogniRbfErrore ogni sostituzione rifiutata scrivonorbf_bump_failedin 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 dibump_feecita 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 - 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
-
[LIM-03] L'attesa in
closingnon ha un limite proprio. Il round aspetta ogni scommessabuilding/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.
- 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.
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 (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. 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_updatedcon 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,
/adminnon collegato dalla pagina pubblica. Dettagli in 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).
Codice:
app/auth/routes.py:login·app/api/routes/users.py:change_password -
[LIM-09] Il JWT sta in
localStoragee 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 cookieHttpOnly.
Codice:
Caddyfile·app/static/app.js - Mitigazione attuale: CSP
-
[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_seedprima 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.
- 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
confirmede 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_confirmationsconferme 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 - 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
-
[LIM-11] Senza server di riserva configurati la corroborazione passa sempre. Con
ELECTRUM_FALLBACK_SERVERSvuoto (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=truein sviluppo) e almeno 2 risposte per ogni corroborazione ([DEC-07]); pin facoltativo del certificato per endpoint (host:porta:pin=<sha256>). 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 - 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
-
[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_winnerdice il contrario). - Pareggi: due scommesse con lo stesso
broadcast_atnon 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=0x207fffffe 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 bloccatip_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
powLimite 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 - Mitigazione attuale: la corroborazione del blocco usato per l'estrazione
([DEC-07], [DEC-25]); non protegge dal blocco di
-
[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.
- 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 - Impatto: un deposito accreditato e poi annullato da una riorganizzazione
più profonda di
-
[LIM-25] Operatore e minatore insieme possono scegliere il vincitore. Con il commit-reveal ([DEC-24]) l'operatore conosce
server_seede può calcolare l'esito di ogni blocco candidato; un minatore d'accordo con lui può scartare i blocchi sfavorevoli. Lo stesso vale per chi rubaserver_seeddal database.- 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.
PalladiumWalletconsidera spendibile un'uscita normale dopoMinConfirmations = 6conferme 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
PalladiumWalletvede 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) possono dirlo.
Codice:
app/tx/confirmation.py:poll_once·app/deposits/service.py:find_new_credit_candidates - Impatto: un utente che preleva verso
Configurazione ed economia
-
[LIM-16] Solo i tempi del round sono fotografati all'apertura. Costo della scommessa, fee rate e
fee_addresssono 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_addressa 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_addressedraw_confirmationssulla 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 - Impatto: cambiare il costo a metà round fa pagare poste diverse a
giocatori con la stessa probabilità, e cambia subito il prelievo minimo;
cambiare
-
[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_inputse il round resta inpaying_out, con unpayout_failedogni 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).
Codice:
app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/wallet/psbt_builder.py:build_payout_transaction - Impatto: raggiunto il limite, il pagamento fallisce con
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
failedche l'utente non può mai vedere. Esistono soloGET /users/me/last-round-result(l'ultimo round giocato) eGET /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-buginvece è 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 (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 undetailstringa (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/usersnon 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