A reply line over asyncio's default 64 KiB StreamReader limit raised out of _read_loop and closed the only connection; the pool address's get_history passes that after ~800 transactions, and dust sent to any public address does it on purpose. - ElectrumClient opens the stream with a 16 MiB limit and frames lines itself: an oversized line is discarded up to its newline, a non-JSON line is dropped, and the request whose id is readable in the first/last 256 bytes fails at once; the connection stays up either way. - Coins below DUST_LIMIT_SATS are never offered as credit candidates, never credited, and select_utxos never selects them. - Spec (ELC-20, DEP-01, DAT-10, ARC), storico, BUGS.md and test counts updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
63 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.
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).
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
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 ARC-03, ARC-05;
SEC-51); le procedure che scrivono in ./data
dovranno allora rispettare quel proprietario.
-
[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'applicazione il server rifiuta di servire se un segreto non è valido (SEC-12), se la master key non si decifra o non corrisponde agli indirizzi già salvati (SEC-49), se una voce diELECTRUM_FALLBACK_SERVERSè malformata (parse_endpoints) o se il quorum minimo non è configurato ([OPS-12]). UnADMIN_TOKENvuoto non blocca l'avvio: il pannello resta chiuso a tutti. Un rifiuto termina il processo; conrestart: unless-stoppedil container riparte e fallisce di nuovo, quindi il sintomo è un container che si riavvia in continuazione, con il motivo nell'output didocker 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.pyStato: da implementare (B-89, B-84) · Oggi: della master key si controlla solo che il file esista e cheXPRV_ENCRYPTION_KEYnon 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.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
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-46), ed è il healthcheck del container
(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,
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_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. Dopo B-73 la stessa chiave protegge anche il segreto dell'estrazione dei round aperti (sicurezza.md SEC-56): è un motivo in più per non conservarla insieme ai backup del database ([OPS-06]). Codice:app/wallet/keystore.py:decrypt_xprv,scripts/decrypt_master_key.py -
[OPS-06] Un backup coerente del database, senza fermare il server, si fa con l'API di backup di SQLite, che include il contenuto del file WAL:
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]. Oggi il file di backup appartiene a root sull'host (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:
docker compose down. Il pubblico non deve raggiungere l'app finché la procedura non è finita:caddysi riavvia solo al passo 10.- 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.pylanciato nel container (vedi [OPS-22], passo 0, per il perché). - Rimettere il database in
./data/db/plm_lottery.dbe cancellare eventuali-wal/-shmrimasti dall'istanza precedente, che non appartengono al file ripristinato. - Mettere in pausa la lotteria nel database ripristinato, prima di
avviare l'app (il valore di
pausedè quello del backup):La modifica non lascia una riga di audit: annotarla altrove. Dopo B-90 si usa invecedocker 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')"START_PAUSED=truein.env([OPS-27]), che scrive anche il motivo della pausa e la riga di audit. - Elencare, sempre ad app ferma, i round non chiusi e le transazioni in
volo del backup:
e, per ogni round trovato, i partecipanti (
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')\")]"SELECT user_id,status,bet_txid FROM round_participants WHERE round_id=<ID>, con lo stesso comando). - 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,closingopaying_outriprende 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_txiddei 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 rigapayoutancorabuilding/pendingva segnatafailed, altrimenti il riconciliatore la abbandonerebbe e lo scheduler ricostruirebbe il pagamento:Unica eccezione: sedocker 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()"payout_txiddella rigapendingè proprio la transazione confermata, non serve toccare nulla, perché ilConfirmationPollerchiude 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
drawingnon 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.
- si guardano le uscite delle sue scommesse verso l'indirizzo del
montepremi (i
- Controllare gli indirizzi riassegnabili con [OPS-28], sempre ad app ferma.
- Avviare solo l'app:
docker compose up -d --build app(il serviziocaddyresta fermo). Le migrazioni si applicano da sole. - In
data/logs/app.logaspettareElectrum connected to …. Alla connessione il listener risottoscrive e aggiorna subito ogni utente (ELC-18), senza aspettare il giro di 300 secondi diDepositReconciler: 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 righebuilding/pendingdel backup al suo primo passaggio con la connessione attiva. Controllare il registro di audit e "Transazioni pendenti". docker compose up -d(riavviacaddy) e "Riprendi lotteria".
Il rischio di replay. Il riallineamento dei saldi è automatico e immediato; quello dei round no. Ogni round del backup non ancora
closedriprende 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); la procedura resta il modo di rimuovere la sospensione. Gli utenti persi non si possono ricostruire dai log:app.lognon 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=truein.envl'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 B-85) e scrivelottery_paused; la pausa resta finché l'operatore non la toglie ("Riprendi lotteria"). Con il valore predefinitofalsel'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:
- Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
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)]" - 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.
- Se nessuno ha cronologia, non c'è nulla da fare. Altrimenti inserire un
account segnaposto per ogni indice da
max + 1fino al più alto con cronologia (compresi quelli senza cronologia in mezzo, perché gli indici si assegnano in ordine):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.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')" - 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, 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 - Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
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 drawingRiprende l'attesa dello stesso blocco di estrazione, con gli stessi partecipanti, e produce lo stesso vincitore (ciclo-di-vita-del-round.md, B-81) 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 secondo le regole di 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.pyStato: da implementare (B-81, B-78) · Oggi: un round indrawingnon riprende: resta indrawingper sempre e nessun nuovo round si apre (serve [OPS-13]); una rigabuildingnon 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):- 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]). - Verificare che
SITE_ADDRESSsia scritto in.env(grep '^SITE_ADDRESS=' .env). Se è stato passato solo sulla riga di comando (SITE_ADDRESS=… docker compose up -d), un semplicedocker compose up -d --buildlo perde e Caddy torna alocalhostcon un certificato autofirmato: il sito pubblico smette di funzionare. - 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, cioè l'ultimo round èclosed) e che in "Transazioni pendenti" non ci siano righebuilding(la sezione elenca anche righe già confermate o fallite: guardare la colonna Stato). - 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).
Perché aspettare
closede 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: durantedrawingblocca il round ([OPS-13]), durante un invio lascia una rigabuilding. Dopo B-93 l'arresto chiude gli stream e le attività entro 30 secondi; il riavvio durantedrawingdiventa 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 - Backup del database ([OPS-06]), copiato fuori dall'host. Annotare la
versione in uso (
Dopo B-93 lo spegnimento chiude gli stream SSE e attende le attività, entro
stop_grace_period: 30s (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 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 headnon 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:- Pausa e attesa di
closedcome in [OPS-10], poi backup ([OPS-06]). - Trovare la revisione di destinazione: nella versione a cui si torna,
dal venv,
alembic heads(legge solo i file di migrazione, non il database). docker compose stop app, poi, con l'immagine nuova ancora in uso,docker compose run --rm --no-deps app alembic downgrade <revisione>.git checkout <versione>edocker compose up -d --build.- 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) - Pausa e attesa di
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.
Dopo B-92 il log applicativo va anche sull'output del container e si legge
con docker compose logs app (architettura.md ARC-09).
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). Due casi diversi:- server irraggiungibili: scommesse e prelievi rispondono 503
network_unavailable; nel logElectrum 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>,pingriesce, 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
drawingcontinua ad aspettare e dopo 360s scrivedraw_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.- 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.
- 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.envsostituirlo 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). - 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-eprova il primario, vedi [OPS-26]). L'altezza stampata si confronta con quella diGET /rounds/current. - 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, 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
- server irraggiungibili: scommesse e prelievi rispondono 503
-
[OPS-12] Le regole del quorum sono di connessione-electrum.md: almeno 2 server di riserva configurati o il server non parte (ELC-19), almeno 2 risposte per ogni corroborazione (ELC-14). 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=truenon si usa mai in produzione. Codice:app/electrum/listener.py:ElectrumListener._corroborate_majority,app/main.py:lifespan· Test:tests/unit/test_electrum_listener.pyStato: da implementare (B-84) · Oggi: conELECTRUM_FALLBACK_SERVERSvuoto (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_faileda ogni blocco).
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. Dopo B-81 lo scheduler riprende da solo un round indrawinge 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 indrawingarrivato da un backup ripristinato si tratta con [OPS-07], passo 6, perché potrebbe essere già stato pagato.- 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à quando la commissione basta al mempool. - 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 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
- 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. Un round inpaying_outsenzapayout_failede con una rigapayoutpendingche non conferma è un pagamento fermo nel mempool: vedi [OPS-17].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 980 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 fra 120 e 240 secondi dopo (al primo passaggio oltre i 120s),payout_txidsi azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log efee_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 rigainsufficient 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. 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
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. Oggi: la transazione non compare nella cronologia del proprio indirizzo secondo il solo server attivo: mai partita (rigabuildingda oltre 120s) o scomparsa dal mempool (rigapendingda 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 èfailede l'utente, che non vede lo storico, va avvisato di ripeterla; per un pagamentopayout_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étxiddel payload né una sua versione precedente (replaced_by_txiddella 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 rigafailedche 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 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. 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 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. 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 scriverbf_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à.
- 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). Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha. - 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 o fermo ([OPS-11]).
- 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: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
- La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
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. Il login ha un freno per username e uno per IP (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_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). Tutti i passi girano nel container, doveMASTER_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 ilMASTER_KEY_PATHdi.env(grep '^MASTER_KEY_PATH=' .env), che su questa installazione punta alla copia./master.xprv.encnella 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.- Pausa, attesa di
closede backup come in [OPS-10], poidocker compose stop app. Dopo B-73 questo passo garantisce anche che nessun round abbia il segreto dell'estrazione ancora cifrato con la chiave vecchia (sicurezza.md SEC-56): tutti i roundclosedsono già rivelati, e il round successivo cifrerà il suo con la chiave nuova. Tenere una copia del file attuale per poter tornare indietro:cp data/keys/master.xprv.enc data/keys/master.xprv.enc.old. - Annotare gli indirizzi derivati con la chiave vecchia:
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))" docker compose run --rm --no-deps app python scripts/decrypt_master_key.pycon la chiave vecchia ancora 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.enval posto della vecchia. docker compose run --rm --no-deps app python scripts/encrypt_master_key.py --overwritee incollare la xprv.- 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
.envla chiave vecchia emaster.xprv.enc.oldal posto del file, e ricominciare. docker compose up -d, controllare il log, riprendere la lotteria.- 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 permessi0644:sudo chmod 600 data/keys/master.xprv.enc. Dopo B-89 gli script scrivono0600e rifiutano una xprv i cui indirizzi non coincidono con quelli del database (SEC-50); dopo B-91 la chiave sta in un file e non in.env(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, attesa di
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 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);/adminmostra 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] reorgRiorganizzazione della catena profonda almeno 2 blocchi ( chain_reorg_detected, ELC-24, 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_externallyepending_tx_resurrectedscritte dopo. Riprendere quando la punta ha superato l'altezza di biforcazione di almenodeposit_confirmationsblocchi senza un'altrachain_reorg_detectedresurrected_txUna transazione dichiarata failedè stata confermata (pending_tx_resurrected, transazioni-e-riconciliazione.md, 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. Secondokinddel payload: prelievo → l'utente ha ricevuto i fondi, e il prelievo risultafailed; 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: confrontarepayout_txiddel round con la catenapayout_blockedLa scommessa del vincitore non è più sulla catena (RND-39, B-82) Il round resta in paying_outconpayout_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 pausaCodice:
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]).
- 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), 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 è 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. Dopo B-80 ogni round ha il proprio indirizzom/84'/746'/0'/2/<round_id>em/84'/746'/0'/1/0diventa 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_heightinGET /rounds/currentNon 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_sinceinGET /rounds/currentclosingopaying_outper più di 3 blocchi (6 minuti);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_abandonedQualunque occorrenza va verificata ([OPS-16]); frequenti: 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_at(primo invio) vecchio 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 Container docker compose psappche si riavvia in continuazione: rifiuto all'avvio ([OPS-02])Prontezza (dopo B-92) GET /health/ready,docker compose ps503 / 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_reasondiverso daoperatorQualunque 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):
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, 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. Codice: —# Prova Come Esito atteso 1 Ciclo completo Due utenti, depositi, una scommessa ciascuno, un round intero Deposito accreditato dopo deposit_confirmationsconferme;draw_committedall'ingresso indrawing; vincitore, seme rivelato e prova diGET /rounds/{id}/proofricalcolabili a mano; ripartizione 70/30 al satoshi; roundclosede round successivo aperto2 Prelievo reale Prelievo parziale e prelievo dell'intero saldo Transazione confermata; importo e resto come in scommesse-e-prelievi.md; il prelievo totale lascia un resto di almeno DUST_LIMIT_SATS3 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 invioNessun rilascio senza prova (B-76): la risposta è 202 o l'errore esplicito; la riga resta buildinge il riconciliatore la promuove se la transazione è in catena; nessun UTXO liberato per una transazione partita4 Riavvio durante drawingdocker compose restart appcon un round indrawingIl round riprende da solo (B-81) con lo stesso draw_heighte gli stessi partecipanti, e il vincitore è quello calcolabile dalla prova5 Pagamento fermo a commissione bassa fee_rate_sat_vb= 1 con mempool affollato, oppure invio del pagamento a un solo server che non lo propagaDopo rbf_timeout_secondsil pagamento viene accelerato riducendo l'uscita di riserva (B-79) e conferma; se l'aumento è impossibile comparerbf_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_detectedcon profondità 1; nessuna pausa. Con profondità 2: pausa con motivoreorg(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_SATSnon vengono accreditate (B-75)8 Tutte le riserve giù Bloccare con il firewall ogni server di ELECTRUM_FALLBACK_SERVERSAccrediti 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/readyrisponde 503 nel frattempo (B-92)10 Salto dell'orologio Portare avanti l'orologio dell'host di 7 ore con transazioni pendingNessuna transazione abbandonata per il solo tempo trascorso (B-78) 11 Disco pieno Riempire il filesystem di ./data/dbdurante 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_detectede 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_KEYdiversa, poi con una master key diversaIl server rifiuta di servire in entrambi i casi (B-89) 14 Deploy con stream aperti docker compose up -d --buildcon pagine/aperteArresto 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.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 altro server, lanciarlo con -e ELECTRUM_HOST=… -e ELECTRUM_PORT=…generate_master_key.pynon ha il ripiego su./data/keys/degli altri due: dal venv scrive esattamente dove diceMASTER_KEY_PATHdi.env. I file scritti hanno i permessi predefiniti del sistema (di norma0644) e, nel container, appartengono a root. Dopo B-89 gli script scrivono0600e rifiutano una xprv diversa da quella degli indirizzi già nel database (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