1457 lines
92 KiB
Markdown
1457 lines
92 KiB
Markdown
# 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`
|