Files
plm-lottery/docs/limiti-noti.md
T
davideandClaude Sonnet 5.5 db24287ea5 Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 +02:00

511 lines
34 KiB
Markdown

# Limiti noti
Questo documento raccoglie 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. Le **decisioni di progetto** non sono
più qui: ognuna è un ADR in [adr/](adr/README.md).
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).
Un limite che il [piano delle correzioni](piano-correzioni.md) chiuderà porta
`*Stato:* da chiudere con B-nn`, e *Per chiuderlo* descrive il comportamento
atteso; quando la correzione entra nel codice, il limite si barra come
`~~[LIM-nn]~~ Rimosso: chiuso da B-nn`.
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
chiuderlo*, e il codice coinvolto.
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
chiuderlo*, e il codice coinvolto.
### Quali limiti bloccano il funzionamento non presidiato
La revisione del 2026-09-28 ha mostrato che la tabella precedente era
incompleta: oltre a `drawing` e all'RBF, bloccavano il funzionamento non
presidiato le falle critiche B-73 … B-82 (estrazione manipolabile, risposte
oltre 64 KiB, abbandoni su un solo server, pagamento senza riserva, deposito
spendibile due volte). La colonna *Dopo il piano* dice cosa resta una volta
implementato il [piano delle correzioni](piano-correzioni.md).
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|---|---|---|
| [LIM-01] `drawing` non riprende dopo un errore o un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco | Chiuso da B-81 |
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85 (la pausa e il registro sono attivi; resta il pagamento, B-82); resta [LIM-24] |
| [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 (resta la lettura delle monete del montepremi, B-82) |
| [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] |
| [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima |
| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | Invariato |
| tutti gli altri | No | — |
### Robustezza del ciclo di vita
- **[LIM-01]** **Lo stato `drawing` non riprende dopo un errore o un
riavvio.** Lo scheduler gestisce `open`, `closing` e `paying_out` a ogni giro
e salta `drawing`; l'attesa del blocco (`_wait_for_draw_block`) vive solo
dentro la chiamata a `_close_and_draw` che l'ha avviata (altezza, elenco e
segreto sono ormai salvati nel database, ma nulla li rilegge). Non serve un riavvio: **qualunque
eccezione** durante l'attesa (un `database is locked` scrivendo una riga di
audit, un header malformato) la interrompe, e nessuno la riprende. Il terzo
audit (2026-08-03) lo ha ribadito come problema **critico**, la revisione
del 2026-09-28 lo ha numerato (B-81).
- *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro;
poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si
ferma**. Con `restart: unless-stopped` un crash in quella finestra è
esattamente il caso che non si ripara da solo.
- *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento
`draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su
`GET /rounds/current`. Il recupero è manuale (vedi
[operativita.md](operativita.md)).
- *Stato:* da chiudere con B-81.
- *Per chiuderlo:* al passaggio in `drawing` si salvano `draw_height`,
`participants_hash` e la posizione di estrazione di ogni partecipante;
`_tick` rientra nell'attesa per un round in `drawing`, in modo idempotente.
Con l'altezza fissa e il seme impegnato di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)/[ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md), un'estrazione
ripresa dà sempre lo stesso vincitore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
sull'intera dimensione della transazione (141 sat per una transazione
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
al minimo di 294 sat ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)) già il primo aumento produce un'uscita sotto
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
round, nel caso normale, non ha alcun resto** (spende esattamente il
montepremi del round, e un resto sotto soglia va alla commissione), quindi
non è accelerabile affatto.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
ciò che il server **non conosce**, una transazione ferma ma presente in
mempool non viene mai abbandonata: una scommessa così blocca il round in
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
registra un errore ogni 30s.
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
sparisce dalla rete; ritentativo automatico del pagamento dopo un
abbandono.
- *Stato:* da chiudere con B-79 e B-87.
- *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che
l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un
resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in
commissione, e quella sostituzione è l'ultima ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md), B-87); ogni
`RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in
audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra
moneta» né CPFP; una transazione che ha esaurito resto o riserva e non
conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non
viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)). 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 è la sua conferma (una qualsiasi delle sue
versioni) oppure il riconciliatore, ma solo con la prova che non è partita
([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); una scommessa ferma ma presente in mempool trattiene il round senza
limite ([LIM-02]).
- *Impatto:* un round può restare chiuso ma non estratto per ore; nessun
nuovo round si apre nel frattempo.
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
«Transazioni pendenti» mostra la scommessa che trattiene il round.
- *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo.
- *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma
(una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non
confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa
resta accelerabile fino all'eliminazione del resto. Un **limite di tempo
resta escluso per scelta**: escludere e rimborsare una scommessa lenta
libererebbe fondi senza prova, che è ciò che [ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md) vieta. Resta quindi
accettato che una scommessa ferma al tetto del fee rate trattenga il
round.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/tx/reconcile.py:_is_due`
- **[LIM-04]** **Prelievo e accelerazione RBF non sono mai stati inviati in una
rete reale.** Sono coperti da test unitari, rami di errore e annullamento
compresi; il resto del flusso (registrazione, deposito, scommessa, un ciclo
completo di round con pagamento) è stato verificato in mainnet.
- *Impatto:* un errore di costruzione o di firma specifico della rete reale
emergerebbe al primo prelievo di un utente vero.
- *Stato:* da chiudere con la fase 7 del [piano](piano-correzioni.md#fase-7--prova-su-rete-reale).
- *Per chiuderlo:* un prelievo e un RBF reali in mainnet, con verifica
dell'aritmetica sulla transazione inviata, dentro la prova con importi
piccoli e guasti provocati di
[operativita.md](operativita.md#prova-su-rete-reale).
*Codice:* `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee`
- **[LIM-05]** **Nessun test di integrazione con una connessione Electrum
vera.** `tests/integration/` è vuota; la verifica dal vivo è stata tutta
manuale (`scripts/electrum_smoke_test.py`, script ad hoc, transazioni reali).
- *Impatto:* le differenze di comportamento fra implementazioni di server
Electrum (formati di risposta, errori) emergono solo in produzione.
- *Per chiuderlo:* lo strato di test di guasto e di integrazione previsto in
[architettura.md](architettura.md#test) (server Electrum simulati che
rispondono in ritardo, tagliano la connessione, mandano risposte enormi o
catene divergenti), più la prova su rete reale di
[operativita.md](operativita.md#prova-su-rete-reale). Nessuna delle falle
critiche del 2026-09-28 sarebbe stata trovata dai test attuali.
*Codice:* `tests/integration/__init__.py` · `scripts/electrum_smoke_test.py`
### Sicurezza e amministrazione
- **[LIM-06]** **Un unico token admin condiviso, senza identità per
operatore.** Il registro di audit dice *cosa* è cambiato (per esempio
`config_updated` con prima/dopo) ma non *chi*. Il token dà accesso a elenco
utenti, esportazione delle chiavi private, reset delle password e storico.
Non c'è limite ai tentativi sul token.
- *Impatto:* una fuga del token ha il raggio d'azione più ampio del sistema,
ed è impossibile attribuire un'azione a una persona.
- *Mitigazione attuale:* confronto a tempo costante, pannello chiuso se il
token è vuoto, ogni azione sensibile in audit, `/admin` non collegato dalla
pagina pubblica. Dettagli in [sicurezza.md](sicurezza.md).
- *Per chiuderlo:* account amministrativi individuali (con l'identità scritta
in ogni voce di audit) e limitazione dei tentativi sul token.
*Codice:* `app/api/routes/admin.py:require_admin` · `app/audit/log.py:write_audit_log`
- **[LIM-07]** **Nessuna limitazione di frequenza su scommesse, prelievi,
endpoint admin, cambio password e segnalazioni di bug.** Solo login e
registrazione sono limitati ([ADR-41](adr/ADR-41-limitazione-tentativi-su-login-registrazione.md)); il canale SSE ha tetti di
concorrenza (500 globali, 5 per IP), non di frequenza.
- *Impatto:* un utente autenticato può martellare scommesse e prelievi (il
blocco per-utente li serializza, non li limita); il cambio password
permette di provare password attuali senza ritardi a chi possiede già un
token valido; le segnalazioni anonime possono riempire il database.
- *Stato:* da chiudere in parte con B-94 (cambio password limitato e
registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256
caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint
admin restano senza limite di frequenza anche dopo il piano.
- *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint
interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`.
*Codice:* `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report`
- **[LIM-08]** **Nessun registro di sicurezza lato utente.** Registrazione,
login, login falliti e cambio password dell'utente non scrivono voci di audit
(le scrive solo il reset da parte dell'admin).
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
avvenuto l'accesso.
- *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di
derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e
falliti restano senza voce di audit dopo il piano.
- *Per chiuderlo:* eventi di audit per questi casi (vedi
[audit-log.md](audit-log.md)).
*Codice:* `app/auth/routes.py:login` · `app/api/routes/users.py:change_password`
- **[LIM-09]** **Il JWT sta in `localStorage` e la CSP ammette script inline.**
Una falla XSS permetterebbe di leggere il token.
- *Mitigazione attuale:* CSP `default-src 'self'`, `frame-ancestors 'none'`,
escape dei valori controllati dall'utente nelle pagine; sessioni revocabili
([ADR-36](adr/ADR-36-password-argon2-sessioni-jwt-hs256-24.md)).
- *Per chiuderlo:* togliere gestori e stili inline dalle SPA per poter
eliminare `'unsafe-inline'`, oppure spostare la sessione in un cookie
`HttpOnly`.
*Codice:* `Caddyfile` · `app/static/app.js`
- **[LIM-26]** **La piattaforma è custodiale: gli utenti devono fidarsi
dell'operatore.** Tutte le chiavi stanno sul server ([ADR-01](adr/ADR-01-piattaforma-custodiale-tutte-chiavi-generate-tenute.md)): l'operatore
(o chi compromette il server) può spendere ogni fondo, leggere
`server_seed` prima della rivelazione e scegliere il vincitore con un
minatore ([LIM-25]). Nessuna misura del piano lo cambia.
- *Impatto:* il rischio di perdita totale dei fondi depositati dipende dalla
sicurezza del server e dall'onestà dell'operatore.
- *Mitigazione dopo il piano:* chiave cifrata a riposo con la chiave di
cifratura in un file separato (B-91), container non root, verifica della
chiave all'avvio (B-89), prova pubblica di ogni estrazione ([ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)), audit
di ogni azione sensibile. Modello di minaccia in
[sicurezza.md](sicurezza.md#modello-di-fiducia).
- *Per chiuderlo:* un disegno non custodiale, scartato in [ADR-01](adr/ADR-01-piattaforma-custodiale-tutte-chiavi-generate-tenute.md).
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv`
### Fiducia nella rete
- **[LIM-10]** **Riorganizzazioni non gestite, depositi a 1 conferma.** Un
deposito accreditato a 1 conferma può sparire con una riorganizzazione; la
punta della catena non regredisce mai, un header concorrente alla stessa
altezza è ignorato e nessuna riorganizzazione viene rilevata.
- *Impatto:* il caso grave è una scommessa **già confermata** che sparisce:
un utente scommette con un deposito a 1 conferma e, se perde, sostituisce
il deposito con una catena privata. La scommessa non è più in catena, ma il
partecipante resta `confirmed` e contato, e il pagamento fallisce per
sempre con «monete del montepremi insufficienti», con il denaro di tutti
gli altri giocatori bloccato. Anche le uscite coinbase sono accreditate a 1
conferma, mentre maturano a 120.
- *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene,
previo quorum, segnata come spesa all'esterno e tolta dal saldo; se
riappare viene ripristinata. Una scommessa **non ancora inviata** costruita
su una moneta scomparsa è rifiutata dalla rete e annullata; per una
scommessa già confermata non esiste alcuna mitigazione (la versione
precedente di questa voce diceva il contrario).
- *Stato:* da chiudere con B-82; le riorganizzazioni sono già rilevate, registrate e (se profonde) in pausa (B-85).
- *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a
120 ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)); riorganizzazioni rilevate, registrate e, se profonde,
con pausa; monete del round verificate prima del pagamento, con i
partecipanti spariti esclusi ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server
rifiuta di partire con meno di 2 server di riserva e ogni corroborazione
richiede almeno 2 risposte ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), B-84), ma più server controllati dalla
stessa parte contano come voci indipendenti. I certificati TLS non sono
verificati contro un'autorità; si possono fissare per endpoint
(`host:porta:pin=<sha256>`, `ELECTRUM_CERT_PIN`).
- *Impatto:* chi controlla la maggioranza dei server configurati (o
raggiungibili) può scegliere il vincitore o alterare i saldi mostrati.
- *Mitigazione attuale:* quorum minimo obbligatorio; meno di 2 risposte →
la corroborazione **fallisce** (una rete irraggiungibile non prova
nulla); pin facoltativo del certificato. Il vecchio caso «senza riserve
tutto passa» e «una sola risposta decide» è chiuso da B-84.
- *Per chiuderlo:* non si chiude: resta una scelta dell'operatore usare
server indipendenti fra loro.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal
solo server attivo: l'elenco delle monete del montepremi usato per il
pagamento.
- *Impatto:* un server ostile potrebbe far scegliere al pagamento monete che
non esistono (il pagamento verrebbe rifiutato dalla rete) o nasconderne
di vere (il pagamento fallirebbe e verrebbe ritentato).
- *Mitigazione attuale:* l'abbandono richiede una prova ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)) e l'invio
arriva a tutti i server; un invio basato su monete inesistenti è rifiutato
dalla rete.
- *Stato:* da chiudere con B-82 (monete del round prima del pagamento);
conferme e ripristini sono chiusi da B-83.
- *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova
merkle valida (`blockchain.transaction.get_merkle`) contro un header che
possediamo; il ripristino richiede lo stesso quorum di un accredito;
l'abbandono richiede la prova di [ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md); le monete del round si
verificano con il quorum prima di costruire il pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)); ogni
transazione accettata dal server attivo viene inviata anche a tutti i
server di riserva.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
- **[LIM-13]** **L'estrazione attuale è manipolabile da chi produce o
trasmette i blocchi.**
- *Minatore:* il seme è il solo hash del blocco, quindi un minatore con
biglietti in gioco può trattenere i blocchi finché uno lo fa vincere. Non
è un'ipotesi remota: la potenza di calcolo di PLM (~22 TH/s) si può
affittare, e scartare qualche blocco costa solo le ricompense perse.
- *Server attivo:* può scegliere quale blocco reale usare semplicemente non
annunciandone uno; la corroborazione verifica che il blocco esista, non che
sia il primo dopo la chiusura, e dopo una corroborazione fallita si passa
comunque al blocco successivo.
- *Verificabilità:* l'ordine dei partecipanti è l'orario di invio registrato
dal server, un dato non pubblico; un giocatore non può ricalcolare da solo
l'esito (il commento di `draw_winner` dice il contrario).
- *Pareggi:* due scommesse con lo stesso `broadcast_at` non hanno un criterio
secondario di ordinamento, quindi l'estrazione non è riproducibile.
- *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti
introduce una distorsione inferiore a 400/2²⁵⁶, accettata anche dopo il
piano.
- *Stato:* chiuso da B-73.
- *Come è chiuso:* il commit-reveal di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md) con l'altezza fissa e le
conferme di [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md): seme che dipende da un segreto impegnato
all'apertura, partecipanti ordinati per txid e fissati da un'impronta,
prova pubblica. Resta accettata la collusione fra operatore e minatore
([LIM-25]).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw`
- **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà
reale.** Il bersaglio dichiarato non può essere più facile del limite di rete
(B-74, [ELC-11](connessione-electrum.md#4-header-validati-non-creduti)) e
ogni salto di più blocchi è verificato come catena, quindi un header falso a
un'altezza enorme non blocca più `tip_height`.
- *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)); non proteggeva dal blocco di `tip_height`, ora chiuso da B-74.
- *Stato:* chiuso da B-74.
- *Come è chiuso:* bersaglio non più facile di `powLimit` e catena
verificata su ogni salto ([ADR-26](adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md)). Resta accettato dopo il piano: senza
il ricalcolo della difficoltà, un header a difficoltà minima (`powLimit`,
in media 2³² hash) costa una frazione di secondo a un miner ASIC e pochi
secondi a una GPU, quindi un
server ostile può ancora produrre una breve catena falsa concatenata alla
nostra. A neutralizzarla sono il quorum sull'header di estrazione
([ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)), il segreto del commit-reveal ([ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)) e il rilevamento delle
riorganizzazioni ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)); una validazione completa con le regole di
ricalcolo della difficoltà di PLM resta fuori scopo.
*Codice:* `app/rounds/draw.py:header_meets_its_own_target` · `app/electrum/listener.py:ElectrumListener._apply_header`
- **[LIM-15]** **La sottoscrizione di un nuovo indirizzo è a sforzo
singolo.** Se fallisce subito dopo la registrazione, l'indirizzo resta non
osservato fino alla riconnessione successiva o al giro di `DepositReconciler`
(300s, che attende prima di partire).
- *Impatto:* un deposito fatto subito dopo la registrazione può tardare fino
a cinque minuti.
*Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` · `app/deposits/reconcile.py:DepositReconciler`
- **[LIM-24]** **Un attacco al 51% su PLM resta possibile: è contenuto, non
impedito.** Chi controlla la maggioranza della potenza di calcolo (che per
PLM, ~22 TH/s, si può affittare) può riscrivere più di 6 blocchi e
annullare depositi già accreditati o scommesse già confermate. Riguarda
chiunque usi PLM, non solo la lotteria.
- *Impatto:* un deposito accreditato e poi annullato da una riorganizzazione
più profonda di `deposit_confirmations` è denaro che la piattaforma ha già
contato; può essere stato scommesso o prelevato.
- *Mitigazione dopo il piano:* 6 conferme sui depositi ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)) rendono
l'attacco costoso quanto sei blocchi dell'intera rete; il rilevamento e la
pausa sulle riorganizzazioni profonde ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)) lo rendono visibile e
fermano nuovi round; la verifica delle monete prima del pagamento fa sì
che un round colpito paghi ciò che resta invece di bloccarsi. Il denaro
sottratto dall'attaccante è perso per la piattaforma.
- *Aperto:* nessuna di queste misure lega il costo dell'attacco alla
dimensione del montepremi. Il tetto opzionale `max_round_pool_sats`
([ADR-21](adr/ADR-21-tre-limiti-distinti-sul-numero-monete.md)) è disattivato per default (0) e, anche impostato, il suo
valore non discende da alcun calcolo del costo di noleggiare potenza
sufficiente a riscrivere `deposit_confirmations` blocchi: resta a
discrezione dell'operatore. Per un montepremi abbastanza grande, quindi,
l'attacco può convenire nonostante le mitigazioni sopra. Decisione
rimandata: se e come legare `max_round_pool_sats` a una stima di quel
costo (o attivarlo per default) non è stato ancora deciso.
- *Per chiuderlo:* non è chiudibile dal software della lotteria.
*Codice:* `app/deposits/service.py:find_new_credit_candidates` · `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
- **[LIM-25]** **Operatore e minatore insieme possono scegliere il
vincitore.** Con il commit-reveal ([ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)) l'operatore conosce
`server_seed` e può calcolare l'esito di ogni blocco candidato; un minatore
d'accordo con lui può scartare i blocchi sfavorevoli. Lo stesso vale per chi
ottiene sia il database sia la chiave di cifratura: il segreto è cifrato a
riposo ([sicurezza.md](sicurezza.md#sec-56) SEC-56), quindi il solo
database, o un suo backup, non basta.
- *Impatto:* un round può essere vinto da un complice.
- *Perché è accettato:* l'operatore è già fidato come custode di tutti i
fondi ([LIM-26]); chi può leggere il database può già fare di peggio.
Il commit-reveal impedisce a chi produce o trasmette i blocchi *da solo*
di scegliere il vincitore, che era il rischio da chiudere.
- *Per chiuderlo:* un contributo di casualità che l'operatore non conosce
(segreti dei giocatori, beacon esterno), scartato in [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/service.py:open_new_round_if_needed`
- **[LIM-27]** **Le soglie di conferma differiscono da quelle del portafoglio
di riferimento.** `PalladiumWallet` considera spendibile un'uscita normale
dopo `MinConfirmations = 6` conferme e una coinbase dopo 121 (maturità 120
più un blocco di margine). La piattaforma adotta 6 solo per i depositi
([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)) e 120 per le coinbase; un prelievo o una vincita, confermati qui
a 1 conferma, compaiono nel portafoglio di riferimento dell'utente come non
ancora spendibili per altri 5 blocchi.
- *Impatto:* un utente che preleva verso `PalladiumWallet` vede i fondi
arrivati ma bloccati per altri 5 blocchi (10 minuti in media) rispetto a
quanto dice la piattaforma; nessun rischio per i fondi.
- *Per chiuderlo:* non serve: è una politica del portafoglio del
destinatario, non una regola di consenso. Le guide
([guida-utente.md](guida-utente.md)) possono dirlo.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates`
### Configurazione ed economia
- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo
della scommessa, fee rate e `fee_address` sono letti dal vivo.
- *Impatto:* cambiare il costo a metà round fa pagare poste diverse a
giocatori con la stessa probabilità, e cambia subito il prelievo minimo;
cambiare `fee_address` a metà round devia la commissione del round in
corso (la modifica è comunque registrata in audit).
- *Stato:* da chiudere con B-95.
- *Per chiuderlo:* fotografare costo, `fee_address` e `draw_confirmations`
sulla riga del round, come già si fa per durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)). Il fee
rate resta letto dal vivo per scelta, e il prelievo minimo segue il costo
corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)).
*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 sugli indirizzi del montepremi (fino a B-80
su uno solo, per tutti i round); il margine fra 400 partecipanti e 500
ingressi ([ADR-21](adr/ADR-21-tre-limiti-distinti-sul-numero-monete.md)) si consumava nel tempo.
- *Impatto:* raggiunto il limite, il pagamento fallisce con
`too_many_inputs` e il round resta in `paying_out`, con un `payout_failed`
ogni 60s.
- *Stato:* la parte del margine che si consumava è chiusa da B-80: con un
indirizzo per round ([ADR-03](adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md)) il pagamento spende solo le monete del proprio
round, e il margine non si consuma più (resta lo stato descritto sopra per
i round aperti prima di B-80, che condividono l'indirizzo di riserva).
- *Resta accettato:* le riserve per l'RBF ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md)) e le monete
estranee inviate agli indirizzi di round chiusi si accumulano senza
consolidamento automatico; recuperarle è un'operazione manuale
([operativita.md](operativita.md#intervento-manuale-sui-fondi)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction`
### Prodotto e interfacce
- **[LIM-18]** **Il sistema presuppone un solo processo.** Canale SSE, blocchi
per-utente, limitatori di tentativi, cache dei QR e della chiave dell'account
sono in memoria.
- *Impatto:* con più worker, due costruzioni dello stesso utente potrebbero
scegliere le stesse monete, i limiti per IP si dividerebbero per worker e
gli eventi SSE arriverebbero solo ai client dello stesso worker.
- *Per chiuderlo:* canale condiviso (Redis pub/sub) e lock su database o
Redis. L'invariante del round attivo è già un indice e non rientra qui.
Il piano del 2026-09-28 **non** lo chiude: un solo processo e un solo file
SQLite restano la scelta di [ADR-13](adr/ADR-13-processo-file-sqlite.md), adeguata alla scala prevista; il piano
rende solo più affidabile quel processo (B-88, B-93).
*Codice:* `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · `app/auth/rate_limit.py:AuthRateLimiters`
- **[LIM-19]** **Nessuna cronologia per l'utente.** L'utente non vede lo storico
dei round, delle scommesse, dei depositi o dei prelievi: un prelievo fallito
lascia una riga `failed` che l'utente non può mai vedere. Esistono solo
`GET /users/me/last-round-result` (l'ultimo round giocato) e
`GET /bug-reports/mine`. L'operatore ha invece `/admin/rounds`,
`/admin/pending-transactions`, `/admin/audit-log`.
- *Per chiuderlo:* endpoint e sezione di cronologia personale.
*Codice:* `app/api/routes/users.py:last_round_result` · `app/withdrawals/service.py:request_withdrawal`
- **[LIM-20]** **`/guida` è un segnaposto.** Il collegamento funziona ma la
pagina dice che la guida arriverà. (`/report-bug` invece è completa e
tradotta.)
*Codice:* `app/main.py:user_guide` · `app/static/guida.html`
- **[LIM-21]** **La parità delle chiavi di traduzione non è verificata da un
test.** Oggi le 7 lingue hanno le stesse 151 chiavi, ma nulla lo impone: una
chiave mancante ricade sull'inglese e, se manca anche lì, viene mostrata la
chiave grezza.
- *Stato:* da chiudere con il test di parità di
[ARC-63](architettura.md#test) (nessun B-nn: non è un difetto del codice).
- *Per chiuderlo:* un test che confronti gli insiemi di chiavi di
`TRANSLATIONS`.
*Codice:* `app/static/i18n.js:t`
- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano
il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un
`detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e
così il 503 del canale SSE oltre il tetto; è coerente con un pannello non
tradotto, ma non è dichiarato altrove.
- *Stato:* da chiudere con B-94.
- *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale
SSE e gli errori generati dal framework usano lo stesso formato
`{code, message, params}` ([ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
*Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream`
- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a
differenza degli altri elenchi admin.
- *Impatto:* con molti utenti la risposta cresce senza limite.
- *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti).
*Codice:* `app/api/routes/admin.py:list_users`