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