# Operatività
Manuale operativo di PLM Lottery: dove stanno i dati, cosa salvare, cosa
succede a un riavvio, e cosa fare quando qualcosa si blocca. Presuppone
installazione e avvio già fatti: la prima configurazione è in
[setup.md](setup.md), i comandi Docker di tutti i giorni in
[running-the-server.md](running-the-server.md), l'uso del pannello in
[guida-admin.md](guida-admin.md). Qui c'è quello che quelle guide non coprono.
Le procedure citano spesso eventi del registro di audit: il loro significato
esatto è in [audit-log.md](audit-log.md). Il funzionamento interno che spiega
*perché* un round si blocca è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md),
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
[connessione-electrum.md](connessione-electrum.md).
**Procedure e requisiti.** Le procedure (i passi numerati) descrivono cosa
fare **con il codice di oggi**; dove una correzione in corso le cambierà, una
nota "Dopo B-nn" dice come. I requisiti marcati *da implementare* descrivono
invece il comportamento atteso, non ancora presente (vedi
[processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
## Topologia
```mermaid
flowchart LR
Internet -- "80 / 443" --> caddy["container caddy
(TLS, header di sicurezza)"]
caddy -- "app:8123" --> app["container app
(FastAPI + 6 attività)"]
app -- "Electrum SSL" --> E1["server Electrum
primario"]
app -. "rotazione e quorum" .-> E2["server di riserva
(ELECTRUM_FALLBACK_SERVERS)"]
app --- db[("./data/db
plm_lottery.db")]
app --- keys[("./data/keys
master.xprv.enc")]
app --- logs[("./data/logs
app.log")]
caddy --- cdata[("volumi Docker
caddy_data, caddy_config")]
```
- **[OPS-01]** Il deployment è composto da due container definiti in
`docker-compose.yml`, entrambi con `restart: unless-stopped`: `app` (questo
codice, porta 8123 esposta solo sulla rete interna) e `caddy` (reverse proxy
sulle porte 80 e 443 dell'host). Il container `app` monta tre cartelle
dell'host: `./data/db` su `/app/db_data`, `./data/keys` su `/app/key_data`,
`./data/logs` su `/app/logs`; `DATABASE_URL` e `MASTER_KEY_PATH` sono
sovrascritti per puntare lì. Caddy tiene certificati e stato ACME nei volumi
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
*Codice:* `docker-compose.yml`, `Caddyfile`
Oggi il container gira come root, e i file che scrive in `./data` (database,
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
gira come utente non root, con `./data` in `0700` e i file di chiave in
`0600`, e la chiave di cifratura sta in un file a parte
([architettura.md](architettura.md) ARC-03, ARC-05;
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
dovranno allora rispettare quel proprietario.
- **[OPS-02]** All'avvio il container `app` esegue, in quest'ordine: controllo
che il file della master key esista (altrimenti esce con errore e un
suggerimento del comando da lanciare), `alembic upgrade head`, avvio del
server. Durante l'avvio dell'applicazione il server rifiuta di servire se
un segreto non è valido ([SEC-12](sicurezza.md#sec-12)), se la master key
non si decifra o non corrisponde agli indirizzi già salvati
([SEC-49](sicurezza.md#sec-49)), se una voce di
`ELECTRUM_FALLBACK_SERVERS` è malformata (`parse_endpoints`) o se il quorum
minimo non è configurato ([OPS-12]). Un `ADMIN_TOKEN` vuoto non blocca
l'avvio: il pannello resta chiuso a tutti. Un rifiuto termina il processo;
con `restart: unless-stopped` il container riparte e fallisce di nuovo,
quindi il sintomo è un container che si riavvia in continuazione, con il
motivo nell'output di `docker compose logs app`.
*Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`,
`app/config.py:validate_runtime_secrets`,
`app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` ·
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-89) · *Oggi:* della master key si controlla
solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una
chiave sbagliata emerge alla prima derivazione.
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
scritture più recenti possono trovarsi solo nel file `-wal`. I tre file vanno
sempre trattati insieme.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
- **[OPS-04]** `/health` risponde sempre `{"status": "ok"}` finché il processo
è vivo: **non** dice nulla sulla connessione Electrum né sullo stato dei
round. Non va usato come unico segnale di salute (vedi
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
*Codice:* `app/main.py:health`
Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
([api.md](api.md) API-46), ed è il `healthcheck` del container
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
viene riavviato da Docker: è un segnale per l'operatore
([OPS-25]), da trattare con [OPS-11] o [OPS-13].
## Backup e ripristino
### Cosa salvare
| Cosa | Dove | Quanto spesso | Perché |
|---|---|---|---|
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) e **prima di ogni deploy** | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
| Master key cifrata | `./data/keys/master.xprv.enc` | Una volta (non cambia) | Da essa derivano tutti gli indirizzi e il montepremi |
| `XPRV_ENCRYPTION_KEY` | `.env` | Una volta, **in un luogo separato** dalla master key | Senza, la master key cifrata è illeggibile |
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum, `SITE_ADDRESS` |
| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano |
Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
legge. Su questa installazione esiste anche una seconda copia,
`./master.xprv.enc` nella radice del progetto, a cui punta il
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
oggi la variabile, separata dalla master key.
- **[OPS-05]** Master key cifrata e `XPRV_ENCRYPTION_KEY` si salvano **in posti
diversi**: uno dei due da solo non basta a spendere, entrambi insieme danno il
controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i
fondi di tutti gli utenti e del montepremi. In alternativa si può conservare
offline la xprv in chiaro, ottenuta con `scripts/decrypt_master_key.py`: in
quel caso quella copia da sola è la chiave di tutto. Dopo B-73 la stessa
chiave protegge anche il segreto dell'estrazione dei round aperti
([sicurezza.md](sicurezza.md#sec-56) SEC-56): è un motivo in più per non
conservarla insieme ai backup del database ([OPS-06]).
*Codice:* `app/wallet/keystore.py:decrypt_xprv`,
`scripts/decrypt_master_key.py`
- **[OPS-06]** Un backup coerente del database, **senza fermare il server**, si
fa con l'API di backup di SQLite, che include il contenuto del file WAL:
```bash
docker compose exec app python -c "import sqlite3; s=sqlite3.connect('/app/db_data/plm_lottery.db'); d=sqlite3.connect('/app/db_data/backup.db'); s.backup(d); d.close(); s.close()"
# il file compare come ./data/db/backup.db sull'host: spostarlo altrove
```
Copiare a mano il solo `plm_lottery.db` mentre il server gira **non** è un
backup affidabile ([OPS-03]). In alternativa: `docker compose stop app`,
copiare i tre file, `docker compose start app`, ma quel riavvio ha le
conseguenze descritte in [OPS-10]. Oggi il file di backup appartiene a root
sull'host ([Topologia](#topologia)).
*Codice:* `app/db/base.py:_register_sqlite_pragmas`
### Ripristino
Un database ripristinato è **indietro rispetto alla catena**: round già
estratti e pagati risultano ancora aperti, scommesse e prelievi fatti dopo il
backup non esistono, utenti registrati dopo il backup sono spariti. La
procedura serve a impedire che il sistema *rigiochi* quel passato con il
denaro di oggi.
- **[OPS-07]** Procedura di ripristino:
1. `docker compose down`. Il pubblico non deve raggiungere l'app finché la
procedura non è finita: `caddy` si riavvia solo al passo 10.
2. Rimettere `.env` (con la stessa `XPRV_ENCRYPTION_KEY` usata per cifrare la
master key) e `./data/keys/master.xprv.enc`. Se si ha solo la xprv in
chiaro, ricifrarla con `scripts/encrypt_master_key.py` lanciato nel
container (vedi [OPS-22], passo 0, per il perché).
3. Rimettere il database in `./data/db/plm_lottery.db` e **cancellare**
eventuali `-wal`/`-shm` rimasti dall'istanza precedente, che non
appartengono al file ripristinato.
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=`,
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= 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= 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".
**Il rischio di replay.** Il riallineamento dei saldi è automatico e
immediato; quello dei round no. Ogni round del backup non ancora `closed`
riprende da solo appena c'è connessione, **anche con la lotteria in pausa**,
e ogni scommessa fatta dopo il backup è persa come partecipazione (il suo
UTXO risulta speso all'esterno, i suoi soldi sono nel montepremi). I passi
4–6 esistono per questo e vanno eseguiti prima del passo 8. Dopo B-80 e B-82
un round rigiocato paga solo gli UTXO del proprio indirizzo, dopo averli
verificati sulla rete, e resta quindi non pagabile invece di essere pagato
due volte; il controllo del passo 6 resta comunque il modo di sapere quali
round del backup sono già conclusi.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/tx/reconcile.py:reconcile_once`,
`app/electrum/listener.py:ElectrumListener._subscribe_all_users`
- **[OPS-08]** **Rischio di un backup vecchio: indirizzi riassegnati.** Il
nuovo utente riceve l'indice di derivazione `max(derivation_index) + 1`.
Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro
indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo
**stesso indirizzo** di un utente perso, e con esso i fondi che quello vi
aveva depositato. Oggi il codice non ha alcuna protezione: l'unica è la
procedura [OPS-28]. Dopo B-90 l'app controlla da sola gli indici successivi
e sospende le registrazioni
([REG-15](registrazione-e-depositi.md#registrazione)); la procedura resta il
modo di rimuovere la sospensione. Gli utenti persi non si possono
ricostruire dai log: `app.log` non registra né username né indice di una
registrazione, e il log di accesso riporta l'indirizzo di Caddy, non quello
del client.
*Codice:* `app/auth/routes.py:register`
- **[OPS-27]** Con `START_PAUSED=true` in `.env` l'app, all'avvio e prima che
lo scheduler faccia il suo primo ciclo, mette in pausa la lotteria come una
pausa dell'operatore (`paused_reason = 'operator'`, senza sovrascrivere un
motivo automatico già presente, [modello-dati.md](modello-dati.md) B-85) e
scrive `lottery_paused`; la pausa resta finché l'operatore non la toglie
("Riprendi lotteria"). Con il valore predefinito `false` l'avvio non tocca
la pausa.
*Codice:* `app/config.py:Settings`, `app/main.py:lifespan`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* —
*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 = , ; [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
riprendono.
*Codice:* `app/wallet/hd.py:derive_user_address`,
`app/auth/security.py:hash_password`, `app/db/models.py:User`
## Riavvio
Un riavvio avviene a ogni deploy (`docker compose up -d --build`), a ogni
modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
- **[OPS-09]** Al riavvio nessuna attività lavora finché il listener non ha una
connessione con un'altezza di catena nota. Poi, a seconda dello stato del
round attivo:
| Stato al momento del riavvio | Cosa succede |
|---|---|
| nessun round / `closed` | Si apre un nuovo round rispettando il cooldown del round precedente |
| `open` | Riprende: la scadenza è calcolata dai valori salvati sul round |
| `closing` | Riprende: si aspetta che le scommesse in volo confermino, poi si estrae |
| `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
riconciliatore secondo le regole di
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)
(B-78): promosse se in catena, abbandonate solo con una prova. Si perdono
invece, perché solo in memoria: i blocchi dei login per troppi tentativi e
le quote per IP, le connessioni SSE (i browser si riconnettono da soli), i
lock per utente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-81, B-78) · *Oggi:* un round in `drawing`
**non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre
(serve [OPS-13]); una riga `building` non trovata nella cronologia del
server attivo viene abbandonata dopo 120 secondi dalla creazione, al primo
passaggio del riconciliatore (ogni 120s) con la connessione attiva.
- **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di
`.env`, rotazione di un segreto):
1. **Backup** del database ([OPS-06]), copiato fuori dall'host. Annotare la
versione in uso (`git rev-parse HEAD`) e la revisione del database
(`docker compose exec app alembic current`): servono per un rollback
([OPS-29]).
2. Verificare che `SITE_ADDRESS` sia scritto in `.env`
(`grep '^SITE_ADDRESS=' .env`). Se è stato passato solo sulla riga di
comando (`SITE_ADDRESS=… docker compose up -d`), un semplice
`docker compose up -d --build` lo perde e Caddy torna a `localhost` con un
certificato autofirmato: il sito pubblico smette di funzionare.
3. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
(`POST /admin/pause`). Il round in corso finisce normalmente.
4. Aspettare che `GET /rounds/current` non riporti più un round attivo
(`round_id` vuoto, cioè l'ultimo round è `closed`) e che in "Transazioni
pendenti" non ci siano righe `building` (la sezione elenca anche righe già
confermate o fallite: guardare la colonna Stato).
5. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
per un cambio di `.env`).
6. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
poi "Riprendi lotteria" (`POST /admin/resume`).
**Perché aspettare `closed` e non solo la pausa.** Oggi l'arresto non è
pulito: con uno stream SSE aperto (basta una pagina `/` aperta) uvicorn
aspetta che si chiuda, e Docker uccide il processo dopo 10 secondi. Ogni
deploy è quindi, per il codice, un crash: durante `drawing` blocca il round
([OPS-13]), durante un invio lascia una riga `building`. Dopo B-93 l'arresto
chiude gli stream e le attività entro 30 secondi; il riavvio
durante `drawing` diventa sicuro con B-81 ([OPS-09]).
La pausa non blocca scommesse e prelievi sul round in corso, né
registrazioni e depositi.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_admin.py`
Dopo B-93 lo spegnimento chiude gli stream SSE e attende le attività, entro
`stop_grace_period: 30s` ([architettura.md](architettura.md) ARC-22); ogni
rilascio ha un'immagine con un tag di versione e `SITE_ADDRESS` è
obbligatorio in `.env`: senza, `docker compose` rifiuta di partire invece di
ripiegare su `localhost` ([architettura.md](architettura.md) ARC-01, ARC-05).
- **[OPS-29]** **Rollback a una versione precedente.** Un'immagine vecchia non
parte su un database con migrazioni più nuove: il suo `alembic upgrade head`
non riconosce la revisione del database e il container esce, e si riavvia in
continuazione. Il database va quindi riportato indietro **con l'immagine
nuova, prima** di cambiare versione:
1. Pausa e attesa di `closed` come in [OPS-10], poi backup ([OPS-06]).
2. Trovare la revisione di destinazione: nella versione a cui si torna,
dal venv, `alembic heads` (legge solo i file di migrazione, non il
database).
3. `docker compose stop app`, poi, con l'immagine nuova ancora in uso,
`docker compose run --rm --no-deps app alembic downgrade `.
4. `git checkout ` e `docker compose up -d --build`.
5. Controllare il log e riprendere la lotteria.
Un downgrade cancella le colonne e le tabelle aggiunte dalle migrazioni
annullate, con i loro dati. Se un downgrade fallisce, si ripristina il
backup del passo 1 di [OPS-10] con [OPS-07], perdendo ciò che è successo
dopo il deploy. Dopo B-93 il passo 4 usa il tag dell'immagine precedente
invece di ricostruirla.
*Codice:* `migrations/`, `Dockerfile` (`CMD`)
## Procedure di intervento
Ogni procedura ha la stessa forma: come si riconosce, perché succede, cosa
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host. I messaggi
dell'applicazione (scheduler, listener, riconciliatori) sono **solo** in quel
file; `docker compose logs app` mostra soltanto l'output di avvio del
container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
conto suo (avvio, richieste, errori di avvio). Vedi
[audit-log.md](audit-log.md#log-applicativo).
Dopo B-92 il log applicativo va anche sull'output del container e si legge
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### Electrum irraggiungibile
- **[OPS-11]**
**Sintomi.** In `/admin` la pillola "Blocco #N" e `chain_tip_height` di
`GET /rounds/current` restano fermi sull'ultimo valore noto (l'altezza non si
azzera alla disconnessione, quindi *non* si vede un errore, si vede un numero
che non cresce). Due casi diversi:
- **server irraggiungibili**: scommesse e prelievi rispondono 503
`network_unavailable`; nel log `Electrum session on … failed`,
`Electrum session on … ending`,
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
altre attività saltano i loro cicli;
- **server connesso ma fermo**: nessun errore nel log, l'ultima riga di
connessione è `Electrum connected to `, `ping` riesce, ma
l'altezza non cresce mentre un explorer o un altro server riportano
blocchi nuovi. Scommesse e prelievi vengono accettati, ma nulla conferma.
In entrambi i casi un round in `drawing` continua ad aspettare e dopo 360s
scrive `draw_stalled`.
**Causa.** Nel primo caso tutti i server configurati sono giù,
irraggiungibili o rifiutati (un header che non supera la validazione chiude
la sessione e fa passare al server successivo). Nel secondo il server attivo
ha smesso di seguire la catena: il listener lo rileva solo se la connessione
cade.
**Cosa fare.**
1. *Server irraggiungibili*: il listener prova il server successivo subito
e, dopo un giro completo di fallimenti, riprova con attesa crescente da 1
a 30 secondi, all'infinito. Se un server torna, la connessione si
ristabilisce da sola.
2. *Server fermo*: il ripristino **non** è automatico. Un riavvio riparte
sempre dal primario (`ELECTRUM_HOST`), quindi aiuta solo se il server
fermo è una riserva. Se il fermo è il primario, in `.env` sostituirlo con
un server che funziona (spostandolo tra le riserve o togliendolo) e
riavviare con [OPS-10]. Dopo B-86 il listener cambia server da solo
quando la punta non avanza da 360s e un altro server ne riporta una più
alta ([ELC-21](connessione-electrum.md#3-caduta-rilevata)).
3. Verificare un server dal container, senza toccare quello in esecuzione:
`docker compose run --rm --no-deps -e ELECTRUM_HOST= -e ELECTRUM_PORT= app python scripts/electrum_smoke_test.py`
(senza `-e` prova il primario, vedi [OPS-26]). L'altezza stampata si
confronta con quella di `GET /rounds/current`.
4. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
riavviare. **Se un round è in `drawing`, oggi il riavvio lo blocca**:
seguire poi [OPS-13].
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
`app/electrum/listener.py:ElectrumListener._keepalive`,
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[OPS-12]** Le regole del quorum sono di
[connessione-electrum.md](connessione-electrum.md): almeno 2 server di
riserva configurati o il server non parte
([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), almeno 2
risposte per ogni corroborazione
([ELC-14](connessione-electrum.md#5-il-quorum)). La conseguenza operativa:
con meno di 2 server di riserva **raggiungibili** ogni corroborazione
fallisce, anche se il server attivo funziona — depositi non accreditati,
estrazione in attesa, segnalazioni di spesa esterna ignorate, nessun denaro
mosso. In produzione vanno quindi configurati almeno 3 server di riserva,
così che la caduta di uno non fermi la lotteria. `ALLOW_NO_QUORUM=true` non
si usa mai in produzione.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`,
`app/main.py:lifespan` ·
*Test:* `tests/unit/test_electrum_listener.py`
### Round bloccato in `drawing`
- **[OPS-13]**
**Sintomi.** `GET /rounds/current` riporta `status: "drawing"` con
`draw_waiting_since` lontano nel tempo; nessun nuovo round si apre. Possibili
cause visibili:
- righe `draw_stalled` ripetute ogni 6 minuti: l'altezza della catena non
sale (Electrum, vedi [OPS-11]);
- righe `draw_header_corroboration_failed`, una per blocco: i blocchi
arrivano ma gli altri server non li confermano (vedi [OPS-12]);
- **nessuna riga nuova e nessun messaggio `still waiting for a block` nel
log**: l'attesa non è più in corso. Succede dopo un riavvio del processo,
oppure dopo un'eccezione durante l'attesa (messaggio
`round scheduler tick failed`, per esempio un database bloccato mentre si
scriveva `draw_stalled`). In questo caso il round non si sbloccherà mai da
solo.
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
Dopo B-81 lo scheduler riprende da solo un round in `drawing` e il terzo
caso scompare.
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
server di riserva): l'estrazione avviene da sola al primo blocco valido.
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
intervento possibile è una modifica manuale del database che riporti il round
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo.
**Vale solo per un round bloccato nell'istanza in esercizio: un round in
`drawing` arrivato da un backup ripristinato si tratta con [OPS-07],
passo 6**, perché potrebbe essere già stato pagato.
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= 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.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`,
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`
### Round bloccato in `closing`
- **[OPS-14]**
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
`has_pending_bets` vero; nel log compare
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
riga `bet` di quel round in `building` o `pending`.
**Causa.** Il round non estrae finché ogni scommessa trasmessa non ha una
conferma. Una scommessa `pending` che resta nel mempool non viene mai
abbandonata dal riconciliatore, che abbandona solo ciò che **non compare**
nella cronologia dell'indirizzo: finché il server Electrum la conosce, anche
non confermata, il round aspetta.
**Cosa fare.**
1. Controllare `attempt_count` e `fee_rate_sat_vb` della riga: il
`RbfBumper` alza la commissione di 1 sat/vB ogni `rbf_timeout_seconds`
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà
quando la commissione basta al mempool.
2. Se nel log compare `could not bump pending_transaction N`, l'RBF è
impossibile: vedi [OPS-17].
3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve
al suo prossimo passaggio (ogni 120s).
4. Una transazione che il server attivo non conosce più viene abbandonata
dopo 6 ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
rimosso e il round può chiudere. Dopo B-78 l'abbandono richiede una prova
(ingressi spesi altrove, o un nuovo invio rifiutato da tutti i server):
una transazione che nessuno conosce ma i cui ingressi non sono spesi non
viene più abbandonata per il solo passare del tempo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
### Round bloccato in `paying_out`
- **[OPS-15]**
**Sintomi.** `status: "paying_out"` a lungo e, nel registro di audit, righe
`payout_failed` dello stesso round, al massimo una al minuto. Il campo
`reason` dice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una
volta rimossa la causa, il pagamento parte al tentativo successivo senza
altri interventi. Un round in `paying_out` senza `payout_failed` e con una
riga `payout` `pending` che non conferma è un pagamento fermo nel mempool:
vedi [OPS-17].
| `reason` | Cosa fare |
|---|---|
| `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 |
| `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) |
| `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 |
**`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati**
dell'indirizzo del montepremi, escludendo quelli già riservati da un altro
pagamento `building` o `pending` non risolto. Controllare in "Transazioni
pendenti" se c'è un `payout` di un round precedente ancora aperto: finché
resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è
mostrato dal pannello (solo `GET /rounds/current` e `GET /admin/rounds` ne
danno `pool_address`); ogni round ha il proprio indirizzo
(`rounds.pool_address`, `m/84'/746'/0'/2/`), e quello di riserva
(`m/84'/746'/0'/1/0`, anche il montepremi dei round aperti prima di B-80) si
ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`;
se ne può controllare il saldo con un explorer. Dopo B-82 un'uscita di
scommessa scomparsa dalla catena esclude quel partecipante invece di
bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca
([OPS-30]).
Un pagamento già in volo (riga `payout` `building` o `pending` del round)
non viene mai duplicato: finché esiste, i tentativi vengono saltati senza
scrivere `payout_failed` e il round aspetta la conferma o l'abbandono.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py`
### Transazione abbandonata
- **[OPS-16]**
**Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log
`abandoned pending_transaction …`; la riga in "Transazioni pendenti"
passa a `failed`.
**Causa.** Oggi: la transazione non compare nella cronologia del proprio
indirizzo secondo il **solo server attivo**: mai partita (riga `building` da
oltre 120s) o scomparsa dal mempool (riga `pending` da oltre 6 ore dal primo
invio). Non è una prova: un server diverso da quello che ha ricevuto l'invio
può non conoscerla, e una versione precedente di una transazione accelerata
con RBF non viene cercata (B-77).
**Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e
saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può
riscommettere se il round è ancora aperto); per un prelievo la richiesta è
`failed` e l'utente, che non vede lo storico, va avvisato di ripeterla; per
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo.
**Prima di avvisare l'utente o lasciar ripartire un pagamento, verificare
con un explorer** che né `txid` del payload né una sua versione precedente
(`replaced_by_txid` della riga) siano in catena: se lo sono, i fondi si sono
mossi davvero e il database dice il contrario. Per un prelievo, gli UTXO
liberati verranno poi segnati come spesi all'esterno dalla corroborazione;
per un pagamento già arrivato al vincitore, mettere subito in pausa la
lotteria e trattare il round come in [OPS-07], passo 6. Se gli abbandoni
sono frequenti, la commissione di rete configurata è probabilmente troppo
bassa. Dopo B-78 si abbandona solo con una prova, e una riga `failed` che
conferma più tardi mette in pausa la lotteria da sola ([OPS-30]).
*Codice:* `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_reconcile.py`
### RBF impossibile
- **[OPS-17]**
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
`could not bump pending_transaction N` con un `RbfError`: "no change output
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
polvere) compare invece come errore di invio nello stesso messaggio.
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
non si può fare. **Un pagamento di norma non ha resto** (spende esattamente
gli UTXO del montepremi), quindi oggi un pagamento fermo nel mempool non si
può accelerare in nessun caso.
**Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa
alla commissione che ha: conferma quando il mempool la accetta, oppure, se
sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se blocca un
round in `closing` ([OPS-14]), l'alternativa manuale è spendere il resto non
confermato con una commissione alta (CPFP) usando la chiave dell'utente
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
chiave del montepremi non è esportabile ([OPS-24]).
Dopo B-79 ogni pagamento ha un'uscita di riserva che l'RBF può ridurre;
dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
toglie il resto invece di fallire, e ogni fallimento scrive
`rbf_bump_failed`.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
*Test:* `tests/unit/test_broadcast.py`
### Deposito non accreditato
- **[OPS-18]**
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
**Cause, in ordine di probabilità.**
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
`deposit_confirmations` (predefinito 6) e 120 per un'uscita coinbase
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha.
2. La corroborazione fallisce: nel log
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
spesso `could not corroborate … with any of N other configured server(s)`.
Vedi [OPS-12].
3. La sottoscrizione dell'indirizzo si è persa (log
`could not subscribe a newly-registered user's address` o
`resubscribing all users failed partway through`).
4. Electrum irraggiungibile o fermo ([OPS-11]).
5. L'importo è sotto `DUST_LIMIT_SATS` (294 sat) e non viene
accreditato per scelta.
**Cosa fare.** Verificare con un explorer che la transazione abbia le
conferme richieste e paghi esattamente l'indirizzo dell'utente (sezione
Utenti del pannello). Poi aspettare: `DepositReconciler` ricontrolla ogni
indirizzo ogni 300 secondi e accredita tutto ciò che il quorum conferma,
indipendentemente dalle notifiche. Un riavvio risottoscrive tutti gli
indirizzi, ma non serve e va fatto con [OPS-10]. Ogni accredito lascia una
riga `deposit_credited`.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`,
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_electrum_listener.py`,
`tests/unit/test_deposit_reconcile.py`
### Utente bloccato fuori
- **[OPS-19]**
**Password dimenticata.** In `/admin` → Utenti → "Reset": viene generata una
nuova password casuale, mostrata una sola volta, da comunicare all'utente per
un canale di cui ci si fida (non esiste un'email). Tutte le sessioni esistenti
dell'utente vengono invalidate. Resta la riga `admin_password_reset`.
Verificare l'identità di chi chiede: chi riceve la password controlla i fondi.
**Troppi tentativi.** Il login ha un freno per username e uno per IP
([SEC-30](sicurezza.md#sec-30)). Basta aspettare che il blocco scada; un
riavvio azzera tutti i contatori ma non è necessario.
*Codice:* `app/api/routes/admin.py:reset_user_password`,
`app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_rate_limit.py`
### Segreto compromesso
- **[OPS-20]** **`ADMIN_TOKEN` trapelato.** Chi lo possiede può esportare la
chiave privata di qualunque utente, cambiare `fee_address`, resettare
password e mettere in pausa la lotteria.
1. Generare un nuovo token
(`python -c "import secrets; print(secrets.token_urlsafe(32))"`),
scriverlo in `.env` e riavviare ([OPS-10], ma senza aspettare la fine del
round se l'urgenza lo richiede ed evitando solo lo stato `drawing`). Il
vecchio token smette di funzionare subito; gli operatori rientrano nel
pannello con quello nuovo.
2. Nel registro di audit cercare, dal momento presunto della fuga:
`admin_privkey_accessed` (quali chiavi sono uscite), `config_updated`
(soprattutto `fee_address`), `admin_password_reset` (account presi), e per
quegli utenti `withdrawal_sent`.
3. **La chiave di un utente esportata resta compromessa per sempre**:
l'indirizzo di un utente è permanente e il codice non ha un modo per
assegnargliene un altro. L'unica difesa è spostare subito i fondi di
quell'indirizzo ([OPS-23]) e avvisare l'utente di non depositarvi più.
*Codice:* `app/api/routes/admin.py:require_admin`
- **[OPS-21]** **`JWT_SECRET` trapelato.** Chi lo possiede può fabbricare una
sessione valida per qualunque utente e quindi scommettere o prelevarne i
fondi. Generare un nuovo segreto di almeno 32 caratteri, scriverlo in `.env` e
riavviare: **tutte** le sessioni esistenti diventano invalide e ogni utente
deve rifare il login. Poi controllare `withdrawal_sent` nel registro di audit
per prelievi inattesi. Per invalidare le sessioni di un solo utente basta
invece un reset della sua password ([OPS-19]).
*Codice:* `app/auth/security.py:decode_access_token`,
`app/config.py:validate_runtime_secrets`
- **[OPS-22]** **Rotazione di `XPRV_ENCRYPTION_KEY`** (la master key non
cambia, cambia solo la sua cifratura). Tutti i passi girano **nel
container**, dove `MASTER_KEY_PATH` è forzato su
`/app/key_data/master.xprv.enc`, cioè il file che l'app legge:
0. **Verificare quale file si sta ruotando.** Il file dell'app è
`./data/keys/master.xprv.enc`. Dal venv gli script usano invece il
`MASTER_KEY_PATH` di `.env` (`grep '^MASTER_KEY_PATH=' .env`), che su
questa installazione punta alla copia `./master.xprv.enc` nella radice
(B-71): una rotazione fatta dal venv riscriverebbe quella copia, l'app
leggerebbe il file vecchio con la chiave nuova e fallirebbe alla prima
derivazione. Per questo nessun passo usa il venv.
1. Pausa, attesa di `closed` e backup come in [OPS-10], poi
`docker compose stop app`. Dopo B-73 questo passo garantisce anche che
nessun round abbia il segreto dell'estrazione ancora cifrato con la
chiave vecchia ([sicurezza.md](sicurezza.md#sec-56) SEC-56): tutti i
round `closed` sono già rivelati, e il round successivo cifrerà il suo
con la chiave nuova. Tenere una copia del file attuale per poter
tornare indietro: `cp data/keys/master.xprv.enc data/keys/master.xprv.enc.old`.
2. Annotare gli indirizzi derivati con la chiave vecchia:
```bash
docker compose run --rm --no-deps app python -c "from app.wallet.hd import derive_pool_address, derive_user_address; print(derive_pool_address(), derive_user_address(0))"
```
3. `docker compose run --rm --no-deps app python scripts/decrypt_master_key.py`
con la chiave vecchia ancora in `.env`, e conservare la xprv stampata
solo per il tempo necessario.
4. Generare la chiave nuova
(`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`)
e scriverla in `.env` al posto della vecchia.
5. `docker compose run --rm --no-deps app python scripts/encrypt_master_key.py --overwrite`
e incollare la xprv.
6. **Verificare la decifratura con la chiave nuova prima di riavviare**:
rilanciare il comando del passo 2. Deve stampare gli stessi due
indirizzi. Se dà errore o indirizzi diversi, non avviare l'app: rimettere
in `.env` la chiave vecchia e `master.xprv.enc.old` al posto del file, e
ricominciare.
7. `docker compose up -d`, controllare il log, riprendere la lotteria.
8. Aggiornare i backup ([OPS-05]) e cancellare `master.xprv.enc.old`. Se la
rotazione è dovuta a una fuga della chiave vecchia, distruggere anche ogni
copia cifrata con essa: la copia nella radice (`./master.xprv.enc`, B-71)
e i backup precedenti del file. Il file scritto dal container appartiene a
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
Dopo B-89 gli script scrivono `0600` e rifiutano una xprv i cui
indirizzi non coincidono con quelli del database
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non
in `.env` ([SEC-53](sicurezza.md#sec-53)).
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
xprv in chiaro), sono a rischio tutti i fondi degli utenti e del montepremi.
Non esiste una procedura supportata per passare a una nuova master key: ogni
indirizzo, compreso quello del montepremi, deriva da essa, e cambiarla
cambierebbe tutti gli indirizzi senza spostare i fondi né aggiornare il
database.
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`,
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`
### Pausa automatica
- **[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 |
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery`,
`app/rounds/service.py:open_new_round_if_needed`,
`app/electrum/listener.py:ElectrumListener._apply_header`,
`app/tx/confirmation.py:poll_once`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-85, B-78, B-82, B-90) · *Oggi:* esiste solo la
pausa dell'operatore, senza motivo; nessun evento del sistema mette in pausa
la lotteria.
### Intervento manuale sui fondi
- **[OPS-23]** **Spostare i fondi di un utente con la sua chiave privata.**
Serve per restituire fondi bloccati, per un CPFP ([OPS-17]) o dopo una fuga
della chiave ([OPS-20]).
1. In "Transazioni pendenti" verificare che l'utente non abbia scommesse o
prelievi `building`/`pending`: i loro input sono già impegnati e
spenderli altrove crea un conflitto che fa fallire una delle due
transazioni.
2. Per evitare che l'utente agisca nel frattempo, si può resettarne la
password ([OPS-19]), che chiude le sue sessioni.
3. `/admin` → Utenti → "Mostra" (riga `admin_privkey_accessed`). La chiave
WIF va importata in un portafoglio PLM (per esempio PalladiumWallet), mai
salvata altrove.
4. Inviare i fondi dove serve.
5. Quando la spesa è confermata, il listener vede gli UTXO scomparire, chiede
conferma agli altri server e li segna come spesi all'esterno
(`utxo_spent_externally`): il saldo nel database scende da solo. Nulla va
modificato a mano.
**Restituire fondi a un utente** non richiede chiavi: si inviano al suo
indirizzo e vengono accreditati come un deposito (`deposit_credited`).
*Codice:* `app/api/routes/admin.py:user_privkey`,
`app/deposits/service.py:mark_utxos_spent_externally`
- **[OPS-24]** **Fondi del montepremi.** La chiave del montepremi
(`m/84'/746'/0'/1/0` della master key) non è esportabile dal pannello né da
alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta con
`scripts/decrypt_master_key.py`. Non c'è quindi una procedura supportata per
consolidare gli UTXO del montepremi (`too_many_inputs`), per recuperare
resti accumulati o per rimborsare una scommessa rimasta senza partecipante.
Spendere UTXO del montepremi fuori dal sistema non è rilevato come spesa
esterna (l'indirizzo non è in `utxo_events`) e può far fallire il pagamento
di un round in corso (`insufficient pool UTXOs` o una trasmissione
rifiutata): farlo solo senza round in `paying_out`. Ogni round ha
il proprio indirizzo `m/84'/746'/0'/2/` e `m/84'/746'/0'/1/0`
è l'indirizzo di riserva; il pagamento di un round spende solo le monete del
suo indirizzo (dopo B-82, solo le uscite delle sue scommesse).
*Codice:* `app/wallet/hd.py:derive_pool_key`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`
## Segnali da tenere d'occhio
- **[OPS-25]** Non esiste un sistema di allarmi: i segnali vanno controllati
a mano o con uno strumento esterno che interroghi l'API e legga il log.
| Segnale | Dove | Allarme se |
|---|---|---|
| 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]) |
| Istanza configurata | `lottery_configured` in `GET /rounds/current` | `false`: manca `fee_address`, nessun round si apre |
| Pausa dimenticata | `lottery_paused` in `GET /rounds/current` | `true` fuori da una manutenzione |
| Pagamenti ed estrazioni | Audit: `payout_failed`, `draw_stalled`, `draw_header_corroboration_failed` | Qualunque occorrenza |
| Transazioni perse | Audit: `pending_tx_abandoned` | Qualunque occorrenza va verificata ([OPS-16]); frequenti: commissione troppo bassa |
| 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]) |
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` (primo invio) vecchio di ore |
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
| Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
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) |
## Script manuali
Si lanciano nel container con `docker compose run --rm --no-deps app python
scripts/.py`, oppure dal venv sull'host (`source .venv/bin/activate`,
con `PYTHONPATH=.`). Nel container `MASTER_KEY_PATH` punta a
`/app/key_data/master.xprv.enc`, cioè allo stesso file `./data/keys` dell'host;
dal venv conta il `MASTER_KEY_PATH` di `.env`, che su questa installazione
punta a un'altra copia (B-71, vedi [OPS-22], passo 0): per gli script sulla
master key usare il container.
- **[OPS-26]** Gli script disponibili:
| Script | Cosa fa | Quando |
|---|---|---|
| `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=…` |
`generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. I
file scritti hanno i permessi predefiniti del sistema (di norma `0644`) e,
nel container, appartengono a root. Dopo B-89 gli script scrivono `0600` e
rifiutano una xprv diversa da quella degli indirizzi già nel database
([SEC-50](sicurezza.md#sec-50)).
*Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`,
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`,
`scripts/electrum_smoke_test.py`