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>
33 KiB
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, i comandi Docker di tutti i giorni in running-the-server.md, l'uso del pannello in 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. Il funzionamento interno che spiega perché un round si blocca è in ciclo-di-vita-del-round.md, transazioni-e-riconciliazione.md e connessione-electrum.md.
Topologia
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 conrestart: unless-stopped:app(questo codice, porta 8123 esposta solo sulla rete interna) ecaddy(reverse proxy sulle porte 80 e 443 dell'host). Il containerappmonta tre cartelle dell'host:./data/dbsu/app/db_data,./data/keyssu/app/key_data,./data/logssu/app/logs;DATABASE_URLeMASTER_KEY_PATHsono sovrascritti per puntare lì. Caddy tiene certificati e stato ACME nei volumi Docker con nomecaddy_dataecaddy_config, non in./data. Codice:docker-compose.yml,Caddyfile -
[OPS-02] All'avvio il container
appesegue, 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'applicazionevalidate_runtime_secretsrifiuta di partire seJWT_SECRETè più corto di 32 caratteri oXPRV_ENCRYPTION_KEYè vuoto, eparse_endpointsrifiuta una voce malformata inELECTRUM_FALLBACK_SERVERS. UnADMIN_TOKENvuoto 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.dbesistonoplm_lottery.db-waleplm_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]
/healthrisponde 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). 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_KEYsi 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 conscripts/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:
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 altroveCopiare a mano il solo
plm_lottery.dbmentre 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:
docker compose down.- Rimettere
.env(con la stessaXPRV_ENCRYPTION_KEYusata per cifrare la master key) e./data/keys/master.xprv.enc. Se si ha solo la xprv in chiaro, ricifrarla conscripts/encrypt_master_key.py. - Rimettere il database in
./data/db/plm_lottery.dbe cancellare eventuali-wal/-shmrimasti dall'istanza precedente, che non appartengono al file ripristinato. docker compose up -d --build. Le migrazioni si applicano da sole.- Controllare in
/adminlo stato del round attivo e le transazioni pendenti; un round rimasto indrawingnel 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 diPOST /auth/registerinapp.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 / closedSi apre un nuovo round rispettando il cooldown del round precedente openRiprende: la scadenza è calcolata dai valori salvati sul round closingRiprende: si aspetta che le scommesse in volo confermino, poi si estrae drawingNon riprende. Il round resta in drawingper sempre e nessun nuovo round si apre. Serve [OPS-13]paying_outRiprende: il pagamento viene ritentato subito se non c'è un payout_failednegli ultimi 60s; un pagamento già in volo non viene duplicatoLe 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):- In
/admin→ Parametri → Manutenzione, "Interrompi dopo questo round" (POST /admin/pause). Il round in corso finisce normalmente. - Aspettare che
GET /rounds/currentnon riporti più un round attivo (round_idvuoto) e che in "Transazioni pendenti" non ci siano scommesse o prelievibuilding. - Riavviare (
docker compose up -d --build, oppuredocker compose up -dper un cambio di.env). - Controllare in
data/logs/app.logla rigaElectrum 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,closingopaying_outè comunque sicuro ([OPS-09]); quello da evitare è il riavvio durantedrawing. Codice:app/api/routes/admin.py:pause_lottery,app/rounds/service.py:open_new_round_if_needed· Test:tests/unit/test_admin.py - In
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).
Electrum irraggiungibile
-
[OPS-11] Sintomi. In
/adminla pillola "Blocco #N" echain_tip_heightdiGET /rounds/currentrestano 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 503network_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 indrawingcontinua ad aspettare e dopo 360s scrivedraw_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.- 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.
- Verificare la raggiungibilità dal server:
docker compose exec app python scripts/electrum_smoke_test.py(prova solo il primario, vedi [OPS-26]). - Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
altri in
ELECTRUM_FALLBACK_SERVERS(elenco di riferimento:ChainProfiles.Mainnet.BootstrapServersdel repository PalladiumWallet) e riavviare. Se un round è indrawing, 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_SERVERSvuoto 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/currentriportastatus: "drawing"condraw_waiting_sincelontano nel tempo; nessun nuovo round si apre. Possibili cause visibili:- righe
draw_stalledripetute 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 blocknel log: l'attesa non è più in corso. Succede dopo un riavvio del processo, oppure dopo un'eccezione durante l'attesa (messaggioround scheduler tick failed, per esempio un database bloccato mentre si scrivevadraw_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 vededrawinge 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:- Annotare l'id del round (dal pannello, sezione Round) e controllare che
sia davvero in
drawinge chewinner_user_idsia vuoto. docker compose stop app.- Aggiornare lo stato:
Deve stampare
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')"1 riga aggiornata. docker compose start app.- Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il
round in
drawingcon un nuovodrawing_started_ate 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 - righe
Round bloccato in closing
- [OPS-14]
Sintomi.
GET /rounds/currentriportastatus: "closing"a lungo, conhas_pending_betsvero; nel log compareround N: N bet(s) still in flight at close time, waitingo nulla (il controllo principale non scrive). In "Transazioni pendenti" c'è almeno una rigabetdi quel round inbuildingopending. Causa. Il round non estrae finché ogni scommessa trasmessa non ha una conferma. Una scommessapendingche 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.- Controllare
attempt_countefee_rate_sat_vbdella riga: ilRbfBumperalza la commissione di 1 sat/vB ognirbf_timeout_seconds(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà da sola. - Se nel log compare
could not bump pending_transaction N, l'RBF è impossibile: vedi [OPS-17]. - Se la riga è
buildingda più di 2 minuti, il riconciliatore la risolve al suo prossimo passaggio (ogni 120s). - 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
- Controllare
Round bloccato in paying_out
-
[OPS-15] Sintomi.
status: "paying_out"a lungo e, nel registro di audit, righepayout_faileddello stesso round, al massimo una al minuto. Il camporeasondice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una volta rimossa la causa, il pagamento parte al tentativo successivo senza altri interventi.reasonCosa fare electrum client not connectedVedi [OPS-11]. Si risolve da sola al ritorno della connessione no fee_address configuredImpostare fee_addressin/admin→ Parametri. Succede solo se è stato svuotato a round in corsowinner user not foundPossibile solo se la riga dell'utente è stata cancellata a mano dal database. Nessuna procedura supportata: ripristinare la riga insufficient pool UTXOsVedi sotto too_many_inputsIl 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_feeIl montepremi è troppo piccolo per la commissione di rete. Abbassare fee_rate_sat_vb; se è già 1, nessuna procedura supportatacommission_below_dust_limitIl 30% è sotto 294 sat (montepremi sotto circa 1.000 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata payout build failedLeggere la traccia nel log ( round N payout build failed)broadcast rejectedSi risolve da sola: la riga buildingviene abbandonata dal riconciliatore entro 2–4 minuti,payout_txidsi azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log efee_rate_sat_vbinsufficient pool UTXOs. Il pagamento usa solo gli UTXO confermati dell'indirizzo del montepremi, escludendo quelli già riservati da un altro pagamentobuildingopendingnon risolto. Controllare in "Transazioni pendenti" se c'è unpayoutdi un round precedente ancora aperto: finché resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è mostrato dal pannello; si ottiene condocker 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
payoutbuildingopendingdel round) non viene mai duplicato: finché esiste, i tentativi vengono saltati senza scriverepayout_failede 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_abandonednel registro di audit; nel logabandoned <tipo> pending_transaction …; la riga in "Transazioni pendenti" passa afailed. Causa. La transazione non compare più nella cronologia del proprio indirizzo: mai partita (rigabuildingda oltre 120s) o scomparsa dal mempool (rigapendingda 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 èfailede l'utente, che non vede lo storico, va avvisato di ripeterla; per un pagamentopayout_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 Ncon unRbfError: "no change output large enough to absorb a fee bump" oppure "already at the maximum fee rate". Nessuna riga di audit.attempt_countsmette 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 inclosing([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à.
- La transazione non ha ancora 1 conferma: il deposito non viene mostrato nemmeno come "in attesa" finché non è in un blocco.
- La corroborazione fallisce: nel log
N new UTXO(s) for user_id=… not corroborated by the other serverse spessocould not corroborate … with any of N other configured server(s). Vedi [OPS-12]. - La sottoscrizione dell'indirizzo si è persa (log
could not subscribe a newly-registered user's addressoresubscribing all users failed partway through). - 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:
DepositReconcilerricontrolla 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 rigadeposit_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 rigaadmin_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_TOKENtrapelato. Chi lo possiede può esportare la chiave privata di qualunque utente, cambiarefee_address, resettare password e mettere in pausa la lotteria.- Generare un nuovo token
(
python -c "import secrets; print(secrets.token_urlsafe(32))"), scriverlo in.enve riavviare ([OPS-10], ma senza aspettare la fine del round se l'urgenza lo richiede ed evitando solo lo statodrawing). Il vecchio token smette di funzionare subito; gli operatori rientrano nel pannello con quello nuovo. - Nel registro di audit cercare, dal momento presunto della fuga:
admin_privkey_accessed(quali chiavi sono uscite),config_updated(soprattuttofee_address),admin_password_reset(account presi), e per quegli utentiwithdrawal_sent. - 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
- Generare un nuovo token
(
-
[OPS-21]
JWT_SECRETtrapelato. 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.enve riavviare: tutte le sessioni esistenti diventano invalide e ogni utente deve rifare il login. Poi controllarewithdrawal_sentnel 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):PYTHONPATH=. python scripts/decrypt_master_key.pycon la chiave vecchia in.env, e conservare la xprv stampata solo per il tempo necessario.- Generare la chiave nuova
(
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") e scriverla in.env. PYTHONPATH=. python scripts/encrypt_master_key.py --overwritee incollare la xprv.- 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]).
- 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. - Per evitare che l'utente agisca nel frattempo, si può resettarne la password ([OPS-19]), che chiude le sue sessioni.
/admin→ Utenti → "Mostra" (rigaadmin_privkey_accessed). La chiave WIF va importata in un portafoglio PLM (per esempio PalladiumWallet), mai salvata altrove.- Inviare i fondi dove serve.
- 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 - In "Transazioni pendenti" verificare che l'utente non abbia scommesse o
prelievi
-
[OPS-24] Fondi del montepremi. La chiave del montepremi (
m/84'/746'/0'/1/0della master key) non è esportabile dal pannello né da alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta conscripts/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 è inutxo_events) e può far fallire il pagamento di un round in corso (insufficient pool UTXOso una trasmissione rifiutata): farlo solo senza round inpaying_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_heightinGET /rounds/currentNon cresce per più di ~10 minuti (blocchi ogni 120s): Electrum giù ([OPS-11]) Stato e durata del round status,closes_at,draw_waiting_sinceinGET /rounds/currentclosingopaying_outper più di qualche blocco;drawingoltre 6 minuti ([OPS-13]–[OPS-15])Istanza configurata lottery_configuredinGET /rounds/currentfalse: mancafee_address, nessun round si aprePausa dimenticata lottery_pausedinGET /rounds/currenttruefuori da una manutenzionePagamenti ed estrazioni Audit: payout_failed,draw_stalled,draw_header_corroboration_failedQualunque occorrenza Transazioni perse Audit: pending_tx_abandonedFrequenti: commissione troppo bassa Spese esterne Audit: utxo_spent_externallyNon 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_resetNon attribuibili a un operatore: ADMIN_TOKENcompromesso ([OPS-20])Transazioni lente /admin→ Transazioni pendentiattempt_countalto obroadcast_atvecchio di oreLog data/logs/app.logall N Electrum server(s) unreachable,round scheduler tick failed,could not bump,invariant violatedDisco ./data/db,./data/logsIl 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.pyCrea una master key casuale, la cifra con XPRV_ENCRYPTION_KEYe la scrive inMASTER_KEY_PATH. Si rifiuta se il file esiste giàUna sola volta, alla prima installazione (setup.md) scripts/encrypt_master_key.pyCifra una xprv incollata (input nascosto) e la scrive in MASTER_KEY_PATH, con ripiego su./data/keys/se la cartella diMASTER_KEY_PATHnon esiste. Rifiuta una xpub e un file già presente senza--overwriteInstallazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) scripts/decrypt_master_key.pyDecifra e stampa la xprv, dopo aver chiesto di scrivere yes. Cerca il file inMASTER_KEY_PATH, poi in./data/keys/master.xprv.encBackup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) scripts/electrum_smoke_test.pySi collega solo al server primario ( ELECTRUM_HOST/PORT/USE_SSL), fa l'handshake, si iscrive agli header e stampa l'altezzaDiagnosi di [OPS-11]. Per provare un server di riserva, lanciarlo con ELECTRUM_HOST/ELECTRUM_PORTimpostati su quel servergenerate_master_key.pynon ha il ripiego su./data/keys/degli altri due: dal venv scrive esattamente dove diceMASTER_KEY_PATHdi.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