944 lines
57 KiB
Markdown
944 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`
|