4. **Mettere in pausa la lotteria nel database ripristinato, prima di
avviare l'app** (il valore di `paused` è quello del backup):
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); c.execute('UPDATE round_config SET paused=1'); c.commit(); print('pausa impostata')"
```
La modifica non lascia una riga di audit: annotarla altrove.
Dopo B-90 si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
scrive anche il motivo della pausa e la riga di audit.
5. **Elencare, sempre ad app ferma, i round non chiusi e le transazioni in
volo** del backup:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print('ROUND'); [print(r) for r in c.execute(\"SELECT id,status,winner_user_id,payout_txid FROM rounds WHERE status!='closed'\")]; print('PENDING'); [print(r) for r in c.execute(\"SELECT id,kind,status,round_id,current_txid FROM pending_transactions WHERE status IN ('building','pending')\")]"
```
e, per ogni round trovato, i partecipanti
(`SELECT user_id,status,bet_txid FROM round_participants WHERE round_id=<ID>`,
con lo stesso comando).
6. **Confrontare ogni round non chiuso con la catena**, con un explorer,
**prima** di avviare l'app. È necessario perché la pausa ferma solo
l'apertura di round nuovi: appena c'è connessione, un round del backup in
`open`, `closing` o `paying_out` riprende da solo, chiude, estrae e paga
([OPS-09]). Oggi tutti i round usano lo stesso indirizzo del montepremi
(`derive_pool_address`, vedi [OPS-15]), quindi un pagamento rifatto
spenderebbe il denaro dei round successivi. Per ogni round:
- si guardano le uscite delle sue scommesse verso l'indirizzo del
montepremi (i `bet_txid` dei partecipanti) e, se c'è, `payout_txid`;
- **se una di quelle uscite è già spesa, o `payout_txid` è confermato**,
il round è già stato pagato dopo il backup. Va chiuso a mano, e ogni
sua riga `payout` ancora `building`/`pending` va segnata `failed`,
altrimenti il riconciliatore la abbandonerebbe e lo scheduler
ricostruirebbe il pagamento:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print(c.execute(\"UPDATE rounds SET status='closed', closed_at=CURRENT_TIMESTAMP WHERE id=<ID> AND status!='closed'\").rowcount, 'round'); print(c.execute(\"UPDATE pending_transactions SET status='failed', failure_reason='restore: round already paid' WHERE kind='payout' AND round_id=<ID> AND status IN ('building','pending')\").rowcount, 'payout'); c.commit()"
```
Unica eccezione: se `payout_txid` della riga `pending` è proprio la
transazione confermata, non serve toccare nulla, perché il
`ConfirmationPoller` chiude il round da sé;
- **se tutte le uscite sono ancora non spese**, il round non è stato
pagato e può riprendere. I giocatori che hanno scommesso su quel round
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
montepremi: annotarli per un rimborso manuale (nessuna procedura
supportata, vedi [OPS-24]);
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
questo controllo: se è già stato pagato, va chiuso come sopra.
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
audit.
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
ferma.
8. Avviare **solo** l'app: `docker compose up -d --build app` (il servizio
`caddy` resta fermo). Le migrazioni si applicano da sole.
9. In `data/logs/app.log` aspettare `Electrum connected to …`. Alla
connessione il listener risottoscrive e aggiorna subito ogni utente
([ELC-18](connessione-electrum.md#riconnessione)), senza aspettare il giro
di 300 secondi di `DepositReconciler`: gli UTXO spesi dopo il backup
(scommesse e prelievi persi dal database) vengono segnati come spesi
all'esterno (`utxo_spent_externally`), quelli arrivati dopo vengono
accreditati (`deposit_credited`). Il riconciliatore risolve le righe
`building`/`pending` del backup al suo primo passaggio con la connessione
attiva. Controllare il registro di audit e "Transazioni pendenti".
10. `docker compose up -d` (riavvia `caddy`) e "Riprendi lotteria".
*Stato:* da implementare (B-90) · *Oggi:* la variabile non esiste; la pausa
si imposta a mano nel database (passo 4 di [OPS-07]).
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
ferma, valida oggi e dopo B-90:
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; from app.wallet.hd import derive_user_address; M=sqlite3.connect('/app/db_data/plm_lottery.db').execute('SELECT COALESCE(MAX(derivation_index),-1) FROM users').fetchone()[0]; print('max', M); [print(i, derive_user_address(i)) for i in range(M+1, M+21)]"
```
2. Controllare con un explorer quali hanno una cronologia. Se ne ha uno fra
gli ultimi della lista, ripetere il passo 1 partendo dal più alto con
cronologia.
3. Se nessuno ha cronologia, non c'è nulla da fare. Altrimenti inserire un
**account segnaposto per ogni indice** da `max + 1` fino al più alto con
cronologia (compresi quelli senza cronologia in mezzo, perché gli indici
si assegnano in ordine):
```bash
docker compose run --rm --no-deps app python -c "import sqlite3, secrets; from datetime import datetime, timezone; from app.auth.security import hash_password; from app.wallet.hd import derive_user_address; c=sqlite3.connect('/app/db_data/plm_lottery.db'); A, B = <DA>, <A>; [c.execute('INSERT INTO users (username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at) VALUES (?, ?, ?, ?, 0, 0, ?)', ('restore-%d' % i, hash_password(secrets.token_urlsafe(32)), i, derive_user_address(i), datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'))) for i in range(A, B + 1)]; c.commit(); print(B - A + 1, 'segnaposto')"
```
La password casuale non viene mostrata a nessuno: l'account non è
utilizzabile finché l'operatore non lo consegna. L'inserimento non
lascia una riga di audit: annotarlo altrove.
4. All'avvio il listener si iscrive anche agli indirizzi segnaposto e
accredita i loro fondi. Quando un utente perso si presenta e dimostra di
conoscere il proprio indirizzo, gli si consegna il segnaposto
corrispondente con un reset della password ([OPS-19]).
Dopo B-90 il controllo automatico di
[REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo,
non trova più indirizzi usati oltre i segnaposto e le registrazioni
| `drawing` | Riprende l'attesa dello stesso blocco di estrazione, con gli stessi partecipanti, e produce lo stesso vincitore ([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), B-81) |
| `paying_out` | Riprende: il pagamento viene ritentato subito se non c'è un `payout_failed` negli ultimi 60s; un pagamento già in volo non viene duplicato |
Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
2. `docker compose stop app`.
3. Aggiornare lo stato:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); n=c.execute(\"UPDATE rounds SET status='closing' WHERE id=<ID> AND status='drawing' AND winner_user_id IS NULL\").rowcount; c.commit(); print(n, 'riga aggiornata')"
```
Deve stampare `1 riga aggiornata`.
4. `docker compose start app`.
5. Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il
round in `drawing` con un nuovo `drawing_started_at` e aspetta un blocco
successivo all'altezza *attuale*; poi estrae e paga normalmente
(`winner_drawn`, `payout_sent`).
Conseguenze da sapere: l'estrazione usa un blocco successivo al momento
dello sblocco invece del primo blocco dopo la chiusura, quindi quale blocco
decide il vincitore dipende da quando l'operatore interviene; la modifica non
lascia alcuna riga nel registro di audit, quindi va annotata altrove, con
data, id del round e motivo. I partecipanti non cambiano: il round è fuori da
`open`, quindi nessuna scommessa nuova può entrare.
| `electrum client not connected` | Vedi [OPS-11]. Si risolve da sola al ritorno della connessione |
| `no fee_address configured` | Impostare `fee_address` in `/admin` → Parametri. Succede solo se è stato svuotato a round in corso |
| `winner user not found` | Possibile solo se la riga dell'utente è stata cancellata a mano dal database. Nessuna procedura supportata: ripristinare la riga |
| `insufficient pool UTXOs` | Vedi sotto |
| `too_many_inputs` | Il pagamento richiederebbe più di 500 UTXO del montepremi (resti accumulati in molti round). Serve consolidare gli UTXO del montepremi con la sua chiave: **nessuno strumento lo fa**, vedi [OPS-24] |
| `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata |
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto 980 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore fra 120 e 240 secondi dopo (al primo passaggio oltre i 120s), `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb`. Oggi lo stesso motivo compare anche per un timeout o una connessione caduta durante l'invio, quando la transazione potrebbe essere partita; dopo B-76 solo un rifiuto esplicito libera la riga |
- **[OPS-30]** Oltre all'operatore, il sistema mette in pausa la lotteria da
sé in tre casi, ognuno con il proprio motivo (`paused_reason`, valori in
[modello-dati.md](modello-dati.md) B-85) e una riga di audit. La pausa ha
l'effetto di sempre: nessun round nuovo si apre, quello in corso prosegue
salvo dove indicato. Solo l'operatore la toglie ("Riprendi lotteria", che
azzera anche il motivo); `/admin` mostra il motivo. Prima di riprendere
l'operatore esegue la verifica della tabella.
| Motivo | Quando | Cosa fare prima di riprendere |
|---|---|---|
| `operator` | "Interrompi dopo questo round", oppure avvio con `START_PAUSED=true` ([OPS-27]) | Dopo un ripristino, le verifiche di [OPS-07] |
| `reorg` | Riorganizzazione della catena profonda almeno 2 blocchi (`chain_reorg_detected`, [ELC-24](connessione-electrum.md#riorganizzazioni), B-85) | Confrontare con un explorer e con i server di riserva ([OPS-11], passo 3) la punta attuale e l'altezza di biforcazione del payload. Controllare le righe `participant_reorged_out`, `utxo_spent_externally` e `pending_tx_resurrected` scritte dopo. Riprendere quando la punta ha superato l'altezza di biforcazione di almeno `deposit_confirmations` blocchi senza un'altra `chain_reorg_detected` |
| `resurrected_tx` | Una transazione dichiarata `failed` è stata confermata (`pending_tx_resurrected`, [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione), B-78) | La riga torna `confirmed`, ma le righe collegate restano come le aveva lasciate l'abbandono: il database dice che i fondi non si sono mossi, la catena il contrario. Secondo `kind` del payload: *prelievo* → l'utente ha ricevuto i fondi, e il prelievo risulta `failed`; gli UTXO liberati verranno segnati come spesi all'esterno dalla corroborazione, ma nel frattempo erano di nuovo spendibili: controllare che l'utente non li abbia già prelevati o scommessi con un'altra transazione; *scommessa* → i soldi sono nel montepremi del round senza partecipante: rimborso manuale ([OPS-24]); *pagamento* → il vincitore potrebbe essere stato pagato due volte: confrontare `payout_txid` del round con la catena |
| `payout_blocked` | La scommessa del vincitore non è più sulla catena ([RND-39](ciclo-di-vita-del-round.md#4--pagamento), B-82) | Il round resta in `paying_out` con `payout_blocked_at`. Verificare con un explorer che l'uscita della scommessa sia davvero spesa altrove (doppia spesa del deposito): è il segno di un probabile attacco, e le prove vanno conservate. Alla ripresa la verifica di RND-39 si ripete: se la scommessa è ancora assente, il pagamento resta bloccato e la lotteria torna in pausa |
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di 10 minuti (blocchi ogni 120s): Electrum giù o fermo ([OPS-11]) |
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di 3 blocchi (6 minuti); `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
| Spese esterne | Audit: `utxo_spent_externally` | Non dovute a un intervento dell'operatore: chiave dell'utente compromessa o server che mente |
| Uso del pannello | Audit: `admin_privkey_accessed`, `config_updated`, `admin_password_reset` | Non attribuibili a un operatore: `ADMIN_TOKEN` compromesso ([OPS-20]) |
connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
connessione caduta.
## Prova su rete reale
I test automatici simulano la rete. Nessuna delle falle critiche della
revisione del 2026-09-28 sarebbe emersa da essi, e prelievo e RBF non sono mai
stati trasmessi davvero. Prima di aprire al pubblico, e dopo le correzioni di
[piano-correzioni.md](piano-correzioni.md), serve una prova su un'istanza di
staging collegata alla rete PLM reale (o, dove indicato, a una rete di prova),
con importi piccoli (costo della scommessa 0,1 PLM) e guasti provocati di
proposito.
- **[OPS-31]** L'istanza si apre al pubblico solo dopo che ogni prova della
tabella è stata eseguita e ha dato l'esito atteso. Per ogni prova si
annotano data, versione (`git rev-parse HEAD`), txid coinvolti ed esito, e
una prova con esito diverso diventa una voce di [BUGS.md](../BUGS.md).
*Codice:* —
| # | Prova | Come | Esito atteso |
|---|---|---|---|
| 1 | Ciclo completo | Due utenti, depositi, una scommessa ciascuno, un round intero | Deposito accreditato dopo `deposit_confirmations` conferme; `draw_committed` all'ingresso in `drawing`; vincitore, seme rivelato e prova di `GET /rounds/{id}/proof` ricalcolabili a mano; ripartizione 70/30 al satoshi; round `closed` e round successivo aperto |
| 2 | Prelievo reale | Prelievo parziale e prelievo dell'intero saldo | Transazione confermata; importo e resto come in [scommesse-e-prelievi.md](scommesse-e-prelievi.md); il prelievo totale lascia un resto di almeno `DUST_LIMIT_SATS` |
| 3 | Server ucciso durante un invio | Interrompere la rete del server Electrum attivo (o `docker compose kill app`) mentre una scommessa e un prelievo sono in invio | Nessun rilascio senza prova (B-76): la risposta è 202 o l'errore esplicito; la riga resta `building` e il riconciliatore la promuove se la transazione è in catena; nessun UTXO liberato per una transazione partita |
| 4 | Riavvio durante `drawing` | `docker compose restart app` con un round in `drawing` | Il round riprende da solo (B-81) con lo stesso `draw_height` e gli stessi partecipanti, e il vincitore è quello calcolabile dalla prova |
| 5 | Pagamento fermo a commissione bassa | `fee_rate_sat_vb` = 1 con mempool affollato, oppure invio del pagamento a un solo server che non lo propaga | Dopo `rbf_timeout_seconds` il pagamento viene accelerato riducendo l'uscita di riserva (B-79) e conferma; se l'aumento è impossibile compare `rbf_bump_failed` (B-87) |
| 6 | Riorganizzazione di 1 blocco | Solo su una rete di prova con due minatori, se disponibile | La punta segue il ramo nuovo senza chiudere la sessione; `chain_reorg_detected` con profondità 1; nessuna pausa. Con profondità 2: pausa con motivo `reorg` (B-85, B-74) |
| 7 | Cronologia molto lunga | Indirizzo con risposta oltre 64 KiB (per esempio mille piccoli invii a un indirizzo di prova) | La connessione resta aperta (limite di 16 MiB); le uscite sotto `DUST_LIMIT_SATS` non vengono accreditate (B-75) |
| 8 | Tutte le riserve giù | Bloccare con il firewall ogni server di `ELECTRUM_FALLBACK_SERVERS` | Accrediti rinviati, estrazione in attesa, nessuna spesa esterna registrata, nessun denaro mosso; al ritorno dei server tutto riprende. Un avvio con meno di 2 riserve è rifiutato (B-84) |
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 nel frattempo (B-92) |
| 10 | Salto dell'orologio | Portare avanti l'orologio dell'host di 7 ore con transazioni `pending` | Nessuna transazione abbandonata per il solo tempo trascorso (B-78) |
| 11 | Disco pieno | Riempire il filesystem di `./data/db` durante l'attività | Nessuna transazione trasmessa senza la sua riga salvata prima; scommesse e prelievi falliscono prima dell'invio; liberato lo spazio, il sistema riprende senza interventi sul database |
| 12 | Ripristino da backup | Backup, poi un round completo e una registrazione, poi [OPS-07] | Avvio in pausa con `START_PAUSED=true`; `restore_gap_detected` e registrazioni rifiutate; il round rigiocato non viene pagato una seconda volta (B-90, B-80, B-82) |
| 13 | Chiave sbagliata | Avvio con una `XPRV_ENCRYPTION_KEY` diversa, poi con una master key diversa | Il server rifiuta di servire in entrambi i casi (B-89) |
| 14 | Deploy con stream aperti | `docker compose up -d --build` con pagine `/` aperte | Arresto completo entro 30 secondi senza uccisione del processo (B-93) |
| `scripts/generate_master_key.py` | Crea una master key casuale, la cifra con `XPRV_ENCRYPTION_KEY` e la scrive in `MASTER_KEY_PATH`. Si rifiuta se il file esiste già | Una sola volta, alla prima installazione ([setup.md](setup.md)) |
| `scripts/encrypt_master_key.py` | Cifra una xprv incollata (input nascosto) e la scrive in `MASTER_KEY_PATH`, con ripiego su `./data/keys/` se la cartella di `MASTER_KEY_PATH` non esiste. Rifiuta una xpub e un file già presente senza `--overwrite` | Installazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) |
| `scripts/decrypt_master_key.py` | Decifra e stampa la xprv, dopo aver chiesto di scrivere `yes`. Cerca il file in `MASTER_KEY_PATH`, poi in `./data/keys/master.xprv.enc` | Backup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) |
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un altro server, lanciarlo con `-e ELECTRUM_HOST=… -e ELECTRUM_PORT=…` |