Files
plm-lottery/docs/decisioni-e-limiti.md
T
2026-09-28 15:36:38 +02:00

1457 lines
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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](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/<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 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`
- **[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/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_db_base.py`
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL` e `BEGIN`
emesso 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 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).
*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. 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 `<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 `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.<code>`), `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.<code>` 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=<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`
- **[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.
- *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
ruba `server_seed` dal 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.** `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`