Safe restore from a backup: START_PAUSED and the derivation-gap check (B-90, REG-15, OPS-27)

START_PAUSED=true pauses the lottery as the operator before the scheduler exists.
On the first Electrum session the 20 derivation indexes past the highest
assigned one are looked up on every server; if any has history, registration
stays closed (503 registration_unavailable) and restore_gap_detected is
written, so a restored database cannot hand a lost user's address to a new one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 00:43:45 +02:00
1 parent 55d852fe08
commit 40ac490826
21 files changed
+281 -70

No files matched your search

+4 -5
View File
@@ -305,12 +305,12 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
usato, **senza distinzione tra maiuscole e minuscole**, anche se
l'altro account si è registrato nello stesso istante);
`409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile
assegnare un indice di derivazione libero); `503 registration_suspended`
assegnare un indice di derivazione libero); `503 registration_unavailable`
(all'avvio sono stati trovati movimenti sulla catena per indici di
derivazione successivi all'ultimo assegnato, cioè il database potrebbe
essere un backup più vecchio della realtà; la registrazione resta
sospesa finché l'operatore non riconcilia,
[operativita.md](operativita.md) B-90 — *da implementare*).
[operativita.md](operativita.md) B-90).
- **Limite di frequenza:** al massimo 5 account creati per IP in una finestra
mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e
consumata solo da una registrazione riuscita (un nome già preso o un
@@ -324,8 +324,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
*Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non
esiste la sospensione `registration_suspended`.
*Stato:* da implementare (B-92) · *Oggi:* non scrive audit.
#### POST /auth/login
@@ -1053,7 +1052,7 @@ dei parametri operativi in
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì |
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
+3 -6
View File
@@ -344,7 +344,7 @@ sequenceDiagram
L->>L: parse_endpoints(...)
L->>L: quorum minimo (validate_quorum_servers)
L->>L: verifica della master key (B-89)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: START_PAUSED (B-90)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
@@ -448,8 +448,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
([operativita.md](operativita.md#ripristino), B-90).
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-90), per il controllo 3 · *Oggi:* il lifespan non
ferma la registrazione né mette in pausa dopo un ripristino.
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
@@ -600,9 +598,8 @@ La configurazione è divisa in due posti con regole diverse.
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile
`XPRV_ENCRYPTION_KEY`.
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE` · *Oggi:* la
chiave Fernet è la variabile `XPRV_ENCRYPTION_KEY`.
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF,
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
+3 -8
View File
@@ -44,7 +44,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/electrum/listener.py:ElectrumListener._record_reorg`
*Stato:* da implementare (B-90, B-96), per due degli eventi
*Stato:* da implementare (B-96), per uno degli eventi
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
`chain_reorg_detected` e `rbf_bump_failed`.
@@ -213,11 +213,7 @@ flowchart LR
`app/electrum/listener.py:ElectrumListener._record_reorg`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-90) · *Oggi:* scritto da
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`), dal
listener (`reorg`) e dallo scheduler (`payout_blocked`); l'avvio con
`START_PAUSED` non lo scrive ancora.
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_restore_gap.py`
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload
`{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la
@@ -294,8 +290,7 @@ flowchart LR
ogni avvio in cui il controllo trova indirizzi usati; transazione propria
([AUD-03]). La sospensione delle registrazioni che ne segue non dipende da
questa riga. Procedura in [operativita.md](operativita.md) (OPS-28).
*Codice:* `app/main.py:lifespan` · *Test:* —
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo e nessun evento.
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap` · *Test:* `tests/unit/test_restore_gap.py`
### Denaro degli utenti
-4
View File
@@ -844,10 +844,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-90) · *Oggi:* si scrivono
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore),
`reorg` (il listener) e `payout_blocked` (lo scheduler); `START_PAUSED` non ha
ancora chi lo scrive.
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
+6 -12
View File
@@ -162,7 +162,7 @@ denaro di oggi.
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
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:
@@ -235,8 +235,7 @@ denaro di oggi.
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
aveva depositato. L'app lo previene: 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
@@ -253,13 +252,11 @@ denaro di oggi.
("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]).
`app/rounds/pause.py:pause_by_operator` ·
*Test:* `tests/unit/test_restore_gap.py`
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
ferma, valida oggi e dopo B-90:
ferma:
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
```bash
@@ -283,7 +280,7 @@ denaro di oggi.
conoscere il proprio indirizzo, gli si consegna il segnaposto
corrispondente con un reset della password ([OPS-19]).
Dopo B-90 il controllo automatico di
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.
@@ -789,9 +786,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
`app/tx/confirmation.py:poll_once`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-90) · *Oggi:* il sistema mette in pausa la
lotteria per `resurrected_tx` (B-78), `reorg` (B-85) e `payout_blocked` (B-82);
l'avvio con `START_PAUSED=true` non esiste ancora.
### Intervento manuale sui fondi
+1 -4
View File
@@ -97,10 +97,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
ricostruire gli utenti mancanti (procedura in
[operativita.md](operativita.md#ripristino), B-90). La registrazione è
rifiutata allo stesso modo finché il controllo non è stato completato.
*Codice:* `app/main.py:lifespan`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* —
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo; dopo un
ripristino il prossimo utente riceve il primo indice non presente nel
database, anche se il suo indirizzo ha già ricevuto fondi.
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* `tests/unit/test_restore_gap.py`
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
transazione che crea l'utente.
+3 -5
View File
@@ -131,8 +131,7 @@ l'host.
riavvio. L'elenco completo è in
[architettura.md](architettura.md#configurazione).
*Codice:* `app/config.py:Settings`
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono.
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE`.
`.env.example` documenta le chiavi necessarie e il comando per generarle;
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
@@ -778,9 +777,8 @@ sicurezza.
successivi al più alto assegnato e, se uno ha cronologia in catena, la
registrazione è rifiutata (`registration_unavailable`) e si scrive
`restore_gap_detected`
([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:*
nessun controllo; l'unica difesa è la procedura
[OPS-28](operativita.md#ripristino).
([REG-15](registrazione-e-depositi.md#registrazione), B-90); la procedura
[OPS-28](operativita.md#ripristino) toglie la sospensione.
- **Round rigiocati.** Con `START_PAUSED=true` il server parte con la
lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round
ha il proprio indirizzo del montepremi, le cui monete sono verificate con
+2 -2
View File
@@ -238,7 +238,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX |
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
@@ -254,7 +254,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-95, B-98, B-99, B-100.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-95, B-98, B-99, B-100.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
3 alti, 5 medi), da implementare nell'ordine di