Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)

- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 01:08:14 +02:00
1 parent 24e5fc3d8b
commit 9520c80dcf
23 files changed
+445 -151

No files matched your search

+2 -14
View File
@@ -117,8 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
modo: torna alla schermata di login.
*Codice:* `app/auth/dependencies.py:get_current_user` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
dell'header è intercettata da FastAPI prima del codice applicativo e
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
@@ -136,7 +134,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
(vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
strutturato `{"detail": "invalid admin token"}`.
- **[API-09]** Intestazioni di cache:
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
@@ -265,8 +262,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/admin.py:update_bug_report_status`,
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
mostra così com'è.
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
@@ -393,8 +388,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter` ·
*Test:* `tests/unit/test_users.py`
limite di frequenza (la password attuale si può provare senza limiti) e
nessuna voce di audit.
#### GET /users/me/last-round-result
@@ -460,8 +453,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
*Stato:* da implementare (B-96) · *Oggi:* un costo coperto solo da resto non
confermato risponde `insufficient_balance`.
#### POST /withdrawals
@@ -507,8 +498,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo
dell'intero saldo non è riconfrontato con il minimo.
### Round
@@ -539,6 +528,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `closing_waiting_since` | stringa ISO o `null` | da quando il round attende in `closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre `status` è `closing` |
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
@@ -713,7 +703,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/bug_reports.py:BugReportCreate`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_bug_reports.py`
anonimo può inserire segnalazioni senza limite.
#### GET /bug-reports/mine
@@ -833,7 +822,6 @@ dei parametri operativi in
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
*Codice:* `app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
in una sola risposta, senza paginazione né limite.
#### GET /admin/users/{user_id}/privkey
@@ -1031,7 +1019,7 @@ dei parametri operativi in
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
+2 -5
View File
@@ -681,9 +681,8 @@ flowchart LR
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
pubblicazione solo sui cambiamenti è in B-96;
`app/tx/reconcile.py:reconcile_once`. Il riconciliatore pubblica solo
per le righe che ha cambiato ([TX-27](transazioni-e-riconciliazione.md#riconciliazione));
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
@@ -691,8 +690,6 @@ flowchart LR
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
## Lock per utente
+4 -17
View File
@@ -44,9 +44,6 @@ 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-96), per uno degli eventi
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
`chain_reorg_detected` e `rbf_bump_failed`.
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
`payload_json`. `created_at` è impostato dall'applicazione, non dal
@@ -114,10 +111,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
*Stato:* da implementare (B-96), per l'ultima lettura ·
*Oggi:* le letture sono quelle di `payout_failed`, di
`draw_baseline_tip_unknown` e di `rbf_bump_failed`.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_state_consistency.py`
## Catalogo degli eventi
@@ -275,7 +269,6 @@ flowchart LR
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
riga.
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
alcun evento.
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
cronologia sulla catena per almeno uno dei 20 indici di derivazione
@@ -386,9 +379,7 @@ flowchart LR
correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì.
Transazione propria ([AUD-03]). La riga più recente del round è l'orologio
della ripetizione successiva, 1800 s dopo ([AUD-09]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive
alcun evento.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* `tests/unit/test_state_consistency.py`
- **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei
partecipanti e altezza del blocco di estrazione sono fissati
@@ -536,9 +527,7 @@ flowchart LR
([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* nessun test verifica la riga di audit.
*Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive
solo `pending_tx_recovered`.
*Test:* `tests/unit/test_state_consistency.py`
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
è stata dichiarata persa **con una prova**
@@ -626,9 +615,7 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-92), per user_registered
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
producono alcuna riga.
*Stato:* da implementare (B-92), per `user_registered`
## Log applicativo
+1 -5
View File
@@ -108,11 +108,7 @@ Le difese sono cinque.
indirizzo non termina la sessione
([DEP-16](registrazione-e-depositi.md#deposito)).
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
aggiornamenti concorrenti) termina la sessione.
*Test:* `tests/unit/test_electrum_client.py`, `tests/unit/test_state_consistency.py`
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
**solo dopo** che il primo header è stato applicato: se il client c'è,
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
+1 -5
View File
@@ -535,11 +535,7 @@ restano nella tabella come storico.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo
`payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e
`bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo
successivo).
`tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
+3 -1
View File
@@ -492,7 +492,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
- **[OPS-14]**
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
`has_pending_bets` vero; nel log compare
`has_pending_bets` vero e `closing_waiting_since` valorizzato; dopo 30 minuti oltre la
scadenza il registro di audit riceve `closing_stalled` (con i txid ancora in volo), ripetuto
ogni 30 minuti ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); nel log compare
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
riga `bet` di quel round in `building` o `pending`.
+4 -15
View File
@@ -123,7 +123,6 @@ il freno sui tentativi di login falliti è
prima di qualunque verifica.
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
*Test:* `tests/unit/test_auth.py`
del login non hanno limiti di lunghezza.
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
specifico del dominio oltre a ciò che SEC-30 già dice.
@@ -144,9 +143,7 @@ il freno sui tentativi di login falliti è
login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md)
e [api.md](api.md) vi rimandano.
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
tentativi, nessun evento di audit.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
L'unica strada è il reset dall'operatore
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
@@ -208,8 +205,7 @@ flowchart TB
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
*Comportamento atteso.* Gli aggiornamenti dello stesso utente non sono
ancora in fila ([DEP-15], B-96).
Gli aggiornamenti dello stesso utente sono messi in fila ([DEP-15]).
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
@@ -328,9 +324,7 @@ ancora in fila ([DEP-15], B-96).
prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una
corroborazione in corso non ritarda una scommessa. Utenti diversi si
aggiornano in parallelo come oggi.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso
utente possono girare insieme e tentare di accreditare la stessa moneta.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_state_consistency.py`
- **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la
connessione Electrum: viene registrato nei log e l'utente viene ripreso
all'aggiornamento successivo. In particolare una violazione dell'unicità
@@ -338,9 +332,7 @@ ancora in fila ([DEP-15], B-96).
si annulla la transazione del database di quell'accredito e si prosegue.
Fa eccezione solo la caduta della connessione stessa, che termina la
sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata).
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da
`refresh_user` a `_consume_scripthash` e termina la sessione Electrum.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* `tests/unit/test_state_consistency.py`
## Il saldo mostrato
@@ -403,6 +395,3 @@ corso (`has_pending`).
la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il
prelievo distingue i due casi; una scommessa coperta solo dal saldo
mostrato riceve `insufficient_balance`.
+4 -11
View File
@@ -38,9 +38,7 @@ Prima dei controlli valgono due condizioni di contorno:
un'attesa sul blocco durante una riconnessione non fa usare una connessione
già chiusa; il controllo di [BET-01] si fa su quel valore.
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
*Test:* `tests/unit/test_state_consistency.py`
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
@@ -54,7 +52,7 @@ cifra. Il **costo** è quello del round ([BET-21]):
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. | `round_full` |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). |
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) |
| **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) |
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
@@ -241,9 +239,7 @@ esterno scelto dall'utente.
avviene tenendo il **blocco per-utente**; il client della rete viene letto
e controllato dopo aver acquisito il blocco, come in [BET-02].
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` ·
*Test:* — (nessun test verifica `network_unavailable`)
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
*Test:* `tests/unit/test_state_consistency.py`
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
@@ -290,10 +286,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats`)
prima che si muova qualunque cifra.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo
sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare
fino a 294 sat meno del minimo.
*Test:* `tests/unit/test_state_consistency.py`
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia)),
le monete spese vengono segnate come tali, il saldo ricalcolato, e si
+5 -17
View File
@@ -323,9 +323,7 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
[REG-12](registrazione-e-depositi.md#sessione-e-password).
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
*Test:* `tests/unit/test_users.py`
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
i campi del login e del cambio password non hanno limite di lunghezza.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
@@ -373,8 +371,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
l'header assente risponde 401 con il corpo non strutturato di FastAPI
`{"detail": "Not authenticated"}`.
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
@@ -396,7 +392,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
non strutturato `{"detail": "invalid admin token"}`.
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
@@ -491,7 +486,6 @@ ogni riavvio e non funzionerebbero con più worker.
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_abuse_limits.py`
la password attuale si può provare senza limiti.
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
@@ -503,7 +497,6 @@ ogni riavvio e non funzionerebbero con più worker.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_abuse_limits.py`
inserire segnalazioni senza limite, fino a riempire il database.
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
@@ -518,9 +511,7 @@ ogni riavvio e non funzionerebbero con più worker.
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
*Oggi:* il 503 ha il corpo non strutturato
`{"detail": "too many concurrent update streams"}`.
*Test:* `tests/unit/test_round_events.py`, `tests/unit/test_abuse_limits.py`
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
@@ -678,10 +669,8 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
validazione e framework compresi — ha il formato strutturato con un codice
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
così una password troppo lunga non torna indietro in chiaro.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
`input` il valore ricevuto.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` ·
*Test:* `tests/unit/test_abuse_limits.py`
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
@@ -753,8 +742,7 @@ sicurezza.
il quorum prima del pagamento
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
del backup già pagato risulta impagabile invece di essere pagato due volte
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
database.
con il denaro dei round successivi.
- **Chiave sbagliata.** Una master key che non corrisponde al database
ripristinato ferma l'avvio ([SEC-49]).
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
+4 -4
View File
@@ -244,7 +244,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | *da registrare* | `test_state_consistency.py` | TX, BET, WDR, DEP |
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR |
| B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC |
@@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-72, 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-90, B-91, B-94, B-95, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93 e B-96 (0 critici,
0 alti, 3 medi), da implementare nell'ordine di
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-91, B-94, B-95, B-96, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-92 e B-93 (0 critici,
0 alti, 2 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-101`.
+6 -18
View File
@@ -328,11 +328,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già
`pending` presente in rete non viene toccata.
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* si scrive solo
`pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno
mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha
`withdrawal_sent`).
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo
se vale una di queste due prove:
1. un suo ingresso risulta **speso in un blocco** da una transazione che
@@ -370,9 +366,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid),
non per ogni riga esaminata.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni
riga che ha interrogato, anche quando non cambia nulla.
*Test:* `tests/unit/test_state_consistency.py`
- **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing`
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in
due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non
@@ -383,16 +377,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
- **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è
**visibile**: quando un round resta in `closing` oltre la scadenza per più
di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un
evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round,
evento `closing_stalled` ([AUD-46](audit-log.md)) con il round,
il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s
finché l'attesa dura; `GET /rounds/current` espone da quando il round attende
(`closing_waiting_since`). L'operatore decide con la procedura di
[operativita.md](operativita.md) (B-96); il sistema non esclude da solo una
[OPS-14](operativita.md#round-bloccato-in-closing); il sistema non esclude da solo una
scommessa in volo dall'estrazione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun
segnale oltre al round che resta in `closing`.
*Test:* `tests/unit/test_state_consistency.py`
## Pagamenti del round: riprovare senza pagare due volte
@@ -414,11 +406,7 @@ queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
`paying_out` senza transazione né importi di un pagamento che non esiste, e
lo scheduler ricostruisce il pagamento al tentativo successivo.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
*Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un
round in `paying_out`, non la sequenza abbandono → ricostruzione)
*Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`;
`winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento
abbandonato.
*Test:* `tests/unit/test_state_consistency.py`
## Dove guardare