Fourth full audit, run against the docs/ specification instead of the code alone, found logical flaws rooted in PLM's small hashrate (~22 TH/s): a minable draw, no distinction between "never broadcast" and "unknown if broadcast", an Electrum client that degrades as the pool address's history grows, and 1-confirmation deposits on a chain where a reorg is rentable. The spec now describes the target behaviour for every finding, marked *Stato:* da implementare (B-nn) with an *Oggi:* line for what the code does now; docs/piano-correzioni.md records the design decisions and the implementation order. No code changed — this is spec-only, per B-nn convention when a bug reveals the spec itself was incomplete. Also documents, as an accepted-but-open risk (LIM-24), that the optional jackpot cap (max_round_pool_sats) isn't tied to the cost of a 51% attack — deferred, not decided. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
957 lines
63 KiB
Markdown
957 lines
63 KiB
Markdown
# 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<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`
|
||
|
||
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, B-84) · *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, e il server parte anche
|
||
senza server di riserva.
|
||
|
||
- **[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.
|
||
*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=<ID>`,
|
||
con lo stesso comando).
|
||
6. **Confrontare ogni round non chiuso con la catena**, con un explorer,
|
||
**prima** di avviare l'app. È necessario perché la pausa ferma solo
|
||
l'apertura di round nuovi: appena c'è connessione, un round del backup in
|
||
`open`, `closing` o `paying_out` riprende da solo, chiude, estrae e paga
|
||
([OPS-09]). Oggi tutti i round usano lo stesso indirizzo del montepremi
|
||
(`derive_pool_address`, vedi [OPS-15]), quindi un pagamento rifatto
|
||
spenderebbe il denaro dei round successivi. Per ogni round:
|
||
- si guardano le uscite delle sue scommesse verso l'indirizzo del
|
||
montepremi (i `bet_txid` dei partecipanti) e, se c'è, `payout_txid`;
|
||
- **se una di quelle uscite è già spesa, o `payout_txid` è confermato**,
|
||
il round è già stato pagato dopo il backup. Va chiuso a mano, e ogni
|
||
sua riga `payout` ancora `building`/`pending` va segnata `failed`,
|
||
altrimenti il riconciliatore la abbandonerebbe e lo scheduler
|
||
ricostruirebbe il pagamento:
|
||
```bash
|
||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print(c.execute(\"UPDATE rounds SET status='closed', closed_at=CURRENT_TIMESTAMP WHERE id=<ID> AND status!='closed'\").rowcount, 'round'); print(c.execute(\"UPDATE pending_transactions SET status='failed', failure_reason='restore: round already paid' WHERE kind='payout' AND round_id=<ID> AND status IN ('building','pending')\").rowcount, 'payout'); c.commit()"
|
||
```
|
||
Unica eccezione: se `payout_txid` della riga `pending` è proprio la
|
||
transazione confermata, non serve toccare nulla, perché il
|
||
`ConfirmationPoller` chiude il round da sé;
|
||
- **se tutte le uscite sono ancora non spese**, il round non è stato
|
||
pagato e può riprendere. I giocatori che hanno scommesso su quel round
|
||
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
|
||
montepremi: annotarli per un rimborso manuale (nessuna procedura
|
||
supportata, vedi [OPS-24]);
|
||
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
|
||
questo controllo: se è già stato pagato, va chiuso come sopra.
|
||
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
|
||
audit.
|
||
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
|
||
ferma.
|
||
8. Avviare **solo** l'app: `docker compose up -d --build app` (il servizio
|
||
`caddy` resta fermo). Le migrazioni si applicano da sole.
|
||
9. In `data/logs/app.log` aspettare `Electrum connected to …`. Alla
|
||
connessione il listener risottoscrive e aggiorna subito ogni utente
|
||
([ELC-18](connessione-electrum.md#riconnessione)), senza aspettare il giro
|
||
di 300 secondi di `DepositReconciler`: gli UTXO spesi dopo il backup
|
||
(scommesse e prelievi persi dal database) vengono segnati come spesi
|
||
all'esterno (`utxo_spent_externally`), quelli arrivati dopo vengono
|
||
accreditati (`deposit_credited`). Il riconciliatore risolve le righe
|
||
`building`/`pending` del backup al suo primo passaggio con la connessione
|
||
attiva. Controllare il registro di audit e "Transazioni pendenti".
|
||
10. `docker compose up -d` (riavvia `caddy`) e "Riprendi lotteria".
|
||
|
||
**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 = <DA>, <A>; [c.execute('INSERT INTO users (username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at) VALUES (?, ?, ?, ?, 0, 0, ?)', ('restore-%d' % i, hash_password(secrets.token_urlsafe(32)), i, derive_user_address(i), datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'))) for i in range(A, B + 1)]; c.commit(); print(B - A + 1, 'segnaposto')"
|
||
```
|
||
La password casuale non viene mostrata a nessuno: l'account non è
|
||
utilizzabile finché l'operatore non lo consegna. L'inserimento non
|
||
lascia una riga di audit: annotarlo altrove.
|
||
4. All'avvio il listener si iscrive anche agli indirizzi segnaposto e
|
||
accredita i loro fondi. Quando un utente perso si presenta e dimostra di
|
||
conoscere il proprio indirizzo, gli si consegna il segnaposto
|
||
corrispondente con un reset della password ([OPS-19]).
|
||
|
||
Dopo B-90 il controllo automatico di
|
||
[REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo,
|
||
non trova più indirizzi usati oltre i segnaposto e le registrazioni
|
||
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 <revisione>`.
|
||
4. `git checkout <versione>` 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 <server>`, `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=<host> -e ELECTRUM_PORT=<porta> 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`
|
||
*Stato:* da implementare (B-84) · *Oggi:* con `ELECTRUM_FALLBACK_SERVERS`
|
||
vuoto (il valore di `.env.example`) il server parte e la corroborazione
|
||
passa sempre: un solo server decide tutto. Con server di riserva la
|
||
maggioranza è su chi risponde, quindi basta una sola riserva raggiungibile;
|
||
se nessuna risponde, la corroborazione fallisce
|
||
(`draw_header_corroboration_failed` a ogni blocco).
|
||
|
||
### 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=<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; 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. Dopo B-80 ogni round ha
|
||
il proprio indirizzo (`rounds.pool_address`), e 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 <tipo> 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. Dopo B-75: 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`. 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`. Dopo B-80 ogni round ha
|
||
il proprio indirizzo `m/84'/746'/0'/2/<round_id>` e `m/84'/746'/0'/1/0`
|
||
diventa l'indirizzo di riserva; il pagamento di un round spende 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/<nome>.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`
|