Files
plm-lottery/docs/operativita.md
T
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:16:01 +02:00

563 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
## Topologia
```mermaid
flowchart LR
Internet -- "80 / 443" --> caddy["container caddy<br/>(TLS, header di sicurezza)"]
caddy -- "app:8123" --> app["container app<br/>(FastAPI + 6 attività)"]
app -- "Electrum SSL" --> E1["server Electrum<br/>primario"]
app -. "rotazione e quorum" .-> E2["server di riserva<br/>(ELECTRUM_FALLBACK_SERVERS)"]
app --- db[("./data/db<br/>plm_lottery.db")]
app --- keys[("./data/keys<br/>master.xprv.enc")]
app --- logs[("./data/logs<br/>app.log")]
caddy --- cdata[("volumi Docker<br/>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`
- **[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 `validate_runtime_secrets` rifiuta
di partire se `JWT_SECRET` è più corto di 32 caratteri o
`XPRV_ENCRYPTION_KEY` è vuoto, e `parse_endpoints` rifiuta una voce malformata
in `ELECTRUM_FALLBACK_SERVERS`. Un `ADMIN_TOKEN` vuoto non blocca l'avvio: il
pannello resta chiuso a tutti.
*Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`,
`app/config.py:validate_runtime_secrets`,
`app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
- **[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`
## Backup e ripristino
### Cosa salvare
| Cosa | Dove | Quanto spesso | Perché |
|---|---|---|---|
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) | 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 |
| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano |
- **[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.
*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].
*Codice:* `app/db/base.py:_register_sqlite_pragmas`
### Ripristino
- **[OPS-07]** Procedura di ripristino:
1. `docker compose down`.
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`.
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. `docker compose up -d --build`. Le migrazioni si applicano da sole.
5. Controllare in `/admin` lo stato del round attivo e le transazioni
pendenti; un round rimasto in `drawing` nel backup va sbloccato con
[OPS-13].
All'avvio il riconciliatore delle transazioni e, dopo 300s, il controllo
periodico dei depositi riallineano il database alla catena: UTXO spesi dopo
il backup vengono segnati come spesi all'esterno (`utxo_spent_externally`),
quelli arrivati dopo vengono accreditati.
*Codice:* `app/tx/reconcile.py:reconcile_once`,
`app/deposits/reconcile.py:DepositReconciler`
- **[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. Il codice non ha alcuna protezione contro questo caso, né
un modo per sospendere le registrazioni. Dopo un ripristino da un backup non
recentissimo, prima di riaprire il servizio, bisogna ricostruire gli utenti
mancanti (ad esempio dai log di `POST /auth/register` in `app.log`, se ancora
presenti) oppure accettare consapevolmente il rischio.
*Codice:* `app/auth/routes.py:register`
## 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` | **Non riprende.** Il round resta in `drawing` per sempre e nessun nuovo round si apre. Serve [OPS-13] |
| `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, non prima di 120 secondi dalla loro creazione e al primo
passaggio con la connessione attiva (i passaggi sono ogni 120s, e quello
"immediato" all'avvio di norma trova la connessione non ancora pronta e viene
saltato): promosse se sono in catena, abbandonate altrimenti. Si perdono invece, perché solo in memoria: i
blocchi dei login per troppi tentativi e la quota di registrazioni 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`
- **[OPS-10]** **Riavvio sicuro** (deploy, modifica di `.env`, rotazione di un
segreto):
1. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
(`POST /admin/pause`). Il round in corso finisce normalmente.
2. Aspettare che `GET /rounds/current` non riporti più un round attivo
(`round_id` vuoto) e che in "Transazioni pendenti" non ci siano scommesse
o prelievi `building`.
3. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
per un cambio di `.env`).
4. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
poi "Riprendi lotteria" (`POST /admin/resume`).
La pausa non blocca scommesse e prelievi sul round in corso, né
registrazioni e depositi. Un riavvio con il round in `open`, `closing` o
`paying_out` è comunque sicuro ([OPS-09]); quello da evitare è il riavvio
durante `drawing`.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_admin.py`
## 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 non compaiono in `docker compose logs`, vedi
[audit-log.md](audit-log.md)).
### 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). 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; un round in `drawing` continua ad
aspettare e dopo 360s scrive `draw_stalled`.
**Causa.** 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).
**Cosa fare.**
1. Nessuna azione è necessaria per il ripristino: 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.
2. Verificare la raggiungibilità dal server:
`docker compose exec app python scripts/electrum_smoke_test.py` (prova
solo il primario, vedi [OPS-26]).
3. 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`, il riavvio lo blocca**: seguire
poi [OPS-13].
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[OPS-12]** Con `ELECTRUM_FALLBACK_SERVERS` vuoto la corroborazione passa
sempre (non c'è nessuno a cui chiedere), ma un solo server guasto ferma
tutto. Con server di riserva configurati ma tutti irraggiungibili, la
corroborazione **fallisce**: depositi non accreditati, estrazione che scarta
ogni blocco (`draw_header_corroboration_failed`) e segnalazioni di spesa
esterna ignorate, anche se il server attivo funziona. In produzione servono
almeno due server di riserva raggiungibili.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*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.
**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:
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.
*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à da
sola.
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 non conosce più viene abbandonata dopo 6
ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
rimosso e il round può chiudere.
*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.
| `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 circa 1.000 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 entro 2–4 minuti, `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` |
**`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; si ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`
e se ne può controllare il saldo con un explorer.
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 <tipo> pending_transaction …`; la riga in "Transazioni pendenti"
passa a `failed`.
**Causa.** La transazione non compare più nella cronologia del proprio
indirizzo: mai partita (riga `building` da oltre 120s) o scomparsa dal
mempool (riga `pending` da oltre 6 ore dal primo invio).
**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. Se
gli abbandoni sono frequenti, la commissione di rete configurata è
probabilmente troppo bassa.
*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.
**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.
**Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa
alla commissione che ha: conferma da sola quando il mempool si svuota,
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.
*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 1 conferma: il deposito non viene mostrato
nemmeno come "in attesa" finché non è in un blocco.
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 ([OPS-11]).
**Cosa fare.** Verificare con un explorer che la transazione sia confermata
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.** Dopo 5 login falliti sullo stesso username (20 dallo
stesso IP) ogni ulteriore errore impone un'attesa che parte da 2 secondi e
raddoppia fino a 300; il contatore si azzera dopo 15 minuti senza errori o al
primo login riuscito. Basta aspettare; 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):
1. `PYTHONPATH=. python scripts/decrypt_master_key.py` con la chiave vecchia
in `.env`, e conservare la xprv stampata solo per il tempo necessario.
2. Generare la chiave nuova
(`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`)
e scriverla in `.env`.
3. `PYTHONPATH=. python scripts/encrypt_master_key.py --overwrite` e
incollare la xprv.
4. Riavviare ([OPS-10]) e aggiornare i backup ([OPS-05]).
**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`
### 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`) o per recuperare
resti accumulati. 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`.
*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ù ([OPS-11]) |
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di qualche blocco; `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` | 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` 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 |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
## Script manuali
Si lanciano dal venv sull'host (`source .venv/bin/activate`, con
`PYTHONPATH=.`) oppure nel container con `docker compose run --rm app python
scripts/<nome>.py`. 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`.
- **[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 server di riserva, lanciarlo con `ELECTRUM_HOST`/`ELECTRUM_PORT` impostati su quel server |
`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`.
*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`