443 lines
26 KiB
Markdown
443 lines
26 KiB
Markdown
# Registro di audit
|
|||
|
|
|
||
|
|
Il registro di audit è la traccia, nel database, di ciò che ha mosso denaro o
|
||
|
|
cambiato la configurazione: scommesse, prelievi, depositi accreditati,
|
||
|
|
estrazioni, pagamenti, interventi dell'operatore. È il primo posto da guardare
|
||
|
|
per ricostruire cosa è successo dopo un problema, ed è l'unica forma di
|
||
|
|
responsabilità che esiste per le azioni dal pannello admin, che non ha
|
||
|
|
un'identità per singolo operatore (vedi [sicurezza.md](sicurezza.md)).
|
||
|
|
|
||
|
|
Questo documento descrive come il registro viene scritto e letto, e cataloga
|
||
|
|
ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||
|
|
[modello-dati.md](modello-dati.md); le procedure che partono da un evento
|
||
|
|
(per esempio un `payout_failed`) sono in [operativita.md](operativita.md).
|
||
|
|
|
||
|
|
## Come si scrive
|
||
|
|
|
||
|
|
- **[AUD-01]** Ogni riga di audit nasce da un'unica funzione,
|
||
|
|
`write_audit_log(session, event_type, payload, user_id=None, round_id=None)`.
|
||
|
|
La funzione **non fa commit**: aggiunge la riga alla sessione del chiamante,
|
||
|
|
che la salva insieme al resto delle sue modifiche. Nessun altro punto del
|
||
|
|
codice crea righe `AuditLog`.
|
||
|
|
*Codice:* `app/audit/log.py:write_audit_log`
|
||
|
|
|
||
|
|
- **[AUD-02]** Per gli eventi che accompagnano un cambiamento di stato, la riga
|
||
|
|
di audit è salvata **nella stessa transazione** del cambiamento che descrive:
|
||
|
|
o si salvano entrambi, o nessuno dei due. Vale per tutti gli eventi del
|
||
|
|
catalogo tranne quelli elencati in [AUD-03].
|
||
|
|
*Codice:* i chiamanti elencati nel catalogo, ognuno dei quali chiama
|
||
|
|
`write_audit_log` e poi `session.commit()` sulla stessa sessione.
|
||
|
|
|
||
|
|
- **[AUD-03]** Quattro eventi sono **solo osservativi**: non accompagnano un
|
||
|
|
cambiamento di stato e vengono salvati in una transazione propria, aperta
|
||
|
|
apposta. Sono `draw_baseline_tip_unknown`, `draw_header_corroboration_failed`,
|
||
|
|
`draw_stalled` e `payout_failed`. Per `payout_failed` la scrittura è anche
|
||
|
|
*best effort*: se fallisce, l'errore viene registrato nel log applicativo
|
||
|
|
(`could not record the payout failure of round …`) e ignorato.
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`,
|
||
|
|
`app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip`,
|
||
|
|
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure`
|
||
|
|
|
||
|
|
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
|
||
|
|
`payload_json`; `created_at` è impostato dal database all'inserimento, in
|
||
|
|
UTC. `user_id` e `round_id` sono chiavi esterne opzionali, valorizzate come
|
||
|
|
indicato evento per evento nel catalogo.
|
||
|
|
*Codice:* `app/audit/log.py:write_audit_log`, `app/db/models.py:AuditLog`
|
||
|
|
|
||
|
|
- **[AUD-05]** Il registro è **in sola aggiunta**: nessuna parte del codice
|
||
|
|
modifica o cancella una riga di `audit_log`, e non c'è alcuna rotazione o
|
||
|
|
scadenza. La tabella cresce per sempre.
|
||
|
|
*Codice:* `app/audit/log.py:write_audit_log` (unico punto di scrittura)
|
||
|
|
|
||
|
|
- **[AUD-06]** `event_type` è una colonna `String(32)`. Il nome più lungo oggi
|
||
|
|
in uso, `draw_header_corroboration_failed`, occupa esattamente 32 caratteri:
|
||
|
|
un nuovo tipo di evento deve restare entro questo limite. SQLite non lo fa
|
||
|
|
rispettare, un altro database lo farebbe.
|
||
|
|
*Codice:* `app/db/models.py:AuditLog`
|
||
|
|
|
||
|
|
## Come si legge
|
||
|
|
|
||
|
|
- **[AUD-07]** `GET /admin/audit-log` (protetto da `X-Admin-Token`) restituisce
|
||
|
|
le righe dalla più recente (ordine per `id` decrescente), con il parametro
|
||
|
|
`limit` (predefinito 200, minimo 1, massimo 500). Ogni elemento contiene
|
||
|
|
`id`, `event_type`, `payload` (già decodificato da JSON), `user_id`,
|
||
|
|
`round_id` e `created_at` in ISO 8601 UTC. **Non esistono filtri** per tipo
|
||
|
|
di evento, utente o round, né paginazione: le righe più vecchie delle ultime
|
||
|
|
500 si leggono solo direttamente dal database.
|
||
|
|
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
|
||
|
|
*Test:* `tests/unit/test_admin.py`
|
||
|
|
|
||
|
|
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
|
||
|
|
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
|
||
|
|
sezione, e mostra id, tipo, payload JSON grezzo, utente, round e data. Non ha
|
||
|
|
filtri né ricerca.
|
||
|
|
*Codice:* `app/static/admin.js:loadAuditLog`
|
||
|
|
|
||
|
|
- **[AUD-09]** Una sola logica del programma rilegge il registro:
|
||
|
|
`_retry_payout_if_due` usa la riga `payout_failed` più recente del round come
|
||
|
|
orologio per decidere se ritentare il pagamento. Se è più recente di 60
|
||
|
|
secondi (`_PAYOUT_RETRY_INTERVAL_SECONDS`) il tentativo viene saltato; se non
|
||
|
|
esiste, il tentativo parte subito. Nessun'altra decisione dipende dal
|
||
|
|
registro.
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
## Catalogo degli eventi
|
||
|
|
|
||
|
|
Legenda delle colonne `user_id` / `round_id`: **sì** = sempre valorizzato,
|
||
|
|
**no** = sempre vuoto, **dipende** = come spiegato nel testo. Gli importi sono
|
||
|
|
sempre in satoshi.
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart LR
|
||
|
|
subgraph Operatore["Pannello admin"]
|
||
|
|
config_updated
|
||
|
|
lottery_paused
|
||
|
|
lottery_resumed
|
||
|
|
admin_privkey_accessed
|
||
|
|
admin_password_reset
|
||
|
|
bug_report_status_changed
|
||
|
|
end
|
||
|
|
subgraph Fondi["Denaro degli utenti"]
|
||
|
|
deposit_credited
|
||
|
|
utxo_spent_externally
|
||
|
|
utxo_external_spend_reinstated
|
||
|
|
bet_placed
|
||
|
|
bet_broadcast_failed
|
||
|
|
withdrawal_sent
|
||
|
|
withdrawal_broadcast_failed
|
||
|
|
end
|
||
|
|
subgraph Round["Round"]
|
||
|
|
round_closed
|
||
|
|
winner_drawn
|
||
|
|
draw_baseline_tip_unknown
|
||
|
|
draw_header_corroboration_failed
|
||
|
|
draw_stalled
|
||
|
|
payout_failed
|
||
|
|
payout_sent
|
||
|
|
end
|
||
|
|
subgraph Ricon["Riconciliazione"]
|
||
|
|
pending_tx_recovered
|
||
|
|
pending_tx_abandoned
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pannello admin
|
||
|
|
|
||
|
|
- **[AUD-10]** `config_updated` — un `PUT /admin/config` ha cambiato almeno un
|
||
|
|
parametro. Payload: un oggetto con una chiave per ogni campo **effettivamente
|
||
|
|
cambiato**, ciascuna `{"from": <valore precedente>, "to": <valore nuovo>}`
|
||
|
|
(i campi sono quelli di `_CONFIG_FIELDS`: `fee_address`, `bet_amount_sats`,
|
||
|
|
`round_duration_seconds`, `round_cooldown_seconds`, `fee_rate_sat_vb`,
|
||
|
|
`rbf_timeout_seconds`, `draw_animation_seconds`). Un campo inviato con lo
|
||
|
|
stesso valore che aveva non compare; se nulla cambia, non si scrive nessuna
|
||
|
|
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
|
||
|
|
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||
|
|
*Test:* `tests/unit/test_admin.py`
|
||
|
|
|
||
|
|
- **[AUD-11]** `lottery_paused` — `POST /admin/pause`. Payload vuoto `{}`.
|
||
|
|
`user_id` no, `round_id` no. Viene scritto a ogni chiamata, anche se la
|
||
|
|
lotteria era già in pausa. Stessa transazione di `paused = true`.
|
||
|
|
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload vuoto `{}`.
|
||
|
|
`user_id` no, `round_id` no. Scritto a ogni chiamata, anche se la lotteria
|
||
|
|
non era in pausa. Stessa transazione di `paused = false`.
|
||
|
|
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
|
||
|
|
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
|
||
|
|
`user_id` sì (l'utente la cui chiave è stata esportata), `round_id` no. La
|
||
|
|
riga è salvata **prima** che la risposta con la chiave venga restituita: se
|
||
|
|
il salvataggio fallisce, la chiave non esce. Per un utente inesistente
|
||
|
|
(404) non si scrive nulla. Questa registrazione non va mai rimossa (vedi
|
||
|
|
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||
|
|
*Codice:* `app/api/routes/admin.py:user_privkey` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit (`tests/unit/test_admin.py`
|
||
|
|
copre solo l'esportazione).
|
||
|
|
|
||
|
|
- **[AUD-14]** `admin_password_reset` — `POST /admin/users/{id}/reset-password`
|
||
|
|
ha impostato una nuova password casuale. Payload `{"user_id"}`; la nuova
|
||
|
|
password **non** compare. `user_id` sì, `round_id` no. Stessa transazione del
|
||
|
|
nuovo hash e dell'incremento di `token_version` (che invalida le sessioni
|
||
|
|
esistenti).
|
||
|
|
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-15]** `bug_report_status_changed` — `POST /admin/bug-reports/{id}/status`
|
||
|
|
ha cambiato lo stato di una segnalazione. Payload
|
||
|
|
`{"report_id", "from", "to"}` con gli stati `open`/`read`/`resolved`.
|
||
|
|
`user_id` dipende: è l'autore della segnalazione, vuoto se era anonima;
|
||
|
|
`round_id` no. Se lo stato richiesto è quello già presente non si scrive
|
||
|
|
nulla. Stessa transazione del cambio di stato.
|
||
|
|
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
|
||
|
|
*Test:* `tests/unit/test_bug_reports.py`
|
||
|
|
|
||
|
|
### Denaro degli utenti
|
||
|
|
|
||
|
|
- **[AUD-16]** `deposit_credited` — un nuovo UTXO confermato sull'indirizzo di
|
||
|
|
un utente è stato accreditato, dopo la corroborazione degli altri server
|
||
|
|
(vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). Una riga
|
||
|
|
per UTXO. Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì,
|
||
|
|
`round_id` no. Stessa transazione dell'inserimento in `utxo_events` e del
|
||
|
|
ricalcolo del saldo. Attenzione: non riguarda solo i depositi dall'esterno.
|
||
|
|
Anche **le vincite** (l'uscita del pagamento verso il vincitore) e **i resti**
|
||
|
|
di scommesse e prelievi arrivano all'indirizzo dell'utente e vengono
|
||
|
|
accreditati per la stessa strada, quindi producono anche loro un
|
||
|
|
`deposit_credited`.
|
||
|
|
*Codice:* `app/deposits/service.py:credit_confirmed_utxos` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-17]** `utxo_spent_externally` — un UTXO che il database considerava
|
||
|
|
non speso non è più tra quelli non spesi, secondo il server attivo e la
|
||
|
|
maggioranza degli altri: è stato speso fuori dalla piattaforma (per esempio
|
||
|
|
con la chiave esportata). Una riga per UTXO. Payload
|
||
|
|
`{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no. Stessa
|
||
|
|
transazione della marcatura (`spent_txid = "external-spend"`) e del ricalcolo
|
||
|
|
del saldo.
|
||
|
|
*Codice:* `app/deposits/service.py:mark_utxos_spent_externally` ·
|
||
|
|
*Test:* `tests/unit/test_deposits.py`
|
||
|
|
|
||
|
|
- **[AUD-18]** `utxo_external_spend_reinstated` — un UTXO segnato come speso
|
||
|
|
all'esterno è ricomparso tra i non spesi e la marcatura è stata annullata.
|
||
|
|
Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no.
|
||
|
|
Stessa transazione dell'annullamento e del ricalcolo del saldo.
|
||
|
|
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
|
||
|
|
*Test:* `tests/unit/test_deposits.py`
|
||
|
|
|
||
|
|
- **[AUD-19]** `bet_placed` — la rete ha accettato la transazione di una
|
||
|
|
scommessa. Payload `{"txid", "amount_sats"}`: `txid` è quello del primo
|
||
|
|
invio (un successivo RBF non aggiorna la riga), `amount_sats` è l'importo
|
||
|
|
che arriva al montepremi, cioè la puntata **al netto** della commissione di
|
||
|
|
rete. `user_id` sì, `round_id` sì. Stessa transazione che porta il
|
||
|
|
partecipante a `broadcast` e la transazione pendente a `pending`.
|
||
|
|
*Codice:* `app/bets/service.py:place_bet` ·
|
||
|
|
*Test:* `tests/unit/test_bets.py`
|
||
|
|
|
||
|
|
- **[AUD-20]** `bet_broadcast_failed` — la rete ha rifiutato la transazione di
|
||
|
|
una scommessa; gli UTXO sono stati liberati e il partecipante rimosso.
|
||
|
|
Payload `{"txid", "reason"}`, con `reason` il testo dell'errore troncato a
|
||
|
|
200 caratteri. `user_id` sì, `round_id` **no** (a differenza di
|
||
|
|
`bet_placed`). Stessa transazione del ripristino.
|
||
|
|
*Codice:* `app/bets/service.py:_release_failed_bet` ·
|
||
|
|
*Test:* `tests/unit/test_bets.py`
|
||
|
|
|
||
|
|
- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un
|
||
|
|
prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`:
|
||
|
|
`amount_sent_sats` è quanto arriva davvero all'indirizzo esterno (al netto
|
||
|
|
della commissione e dell'eventuale limite di polvere, vedi
|
||
|
|
[scommesse-e-prelievi.md](scommesse-e-prelievi.md)). L'id del prelievo non è
|
||
|
|
nel payload. `user_id` sì, `round_id` no. Stessa transazione che porta il
|
||
|
|
prelievo a `broadcast`.
|
||
|
|
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-22]** `withdrawal_broadcast_failed` — la rete ha rifiutato la
|
||
|
|
transazione di un prelievo; gli UTXO sono stati liberati e il prelievo
|
||
|
|
segnato `failed`. Payload `{"txid", "reason"}` (`reason` troncato a 200
|
||
|
|
caratteri). `user_id` sì, `round_id` no. Stessa transazione del ripristino.
|
||
|
|
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
### Round
|
||
|
|
|
||
|
|
- **[AUD-23]** `round_closed` — un round è stato chiuso **senza partecipanti
|
||
|
|
confermati**. Payload `{"participants": 0}`. `user_id` no, `round_id` sì.
|
||
|
|
Stessa transazione del passaggio a `closed`. È l'unico caso in cui la
|
||
|
|
chiusura di un round viene registrata: un round chiuso dopo la conferma del
|
||
|
|
pagamento non produce alcuna riga (vedi [AUD-32]).
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-24]** `winner_drawn` — il vincitore è stato estratto. Payload
|
||
|
|
`{"winner_address", "pool_amount_sats", "block_height", "block_hash",
|
||
|
|
"participants"}`: indirizzo del vincitore, montepremi totale (somma delle
|
||
|
|
puntate nette), altezza e hash del blocco usato come seme, numero di
|
||
|
|
partecipanti. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
|
||
|
|
che salva i dati dell'estrazione sul round e lo porta a `paying_out`.
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-25]** `draw_baseline_tip_unknown` — al momento della chiusura
|
||
|
|
l'altezza della catena non era nota, quindi il primo blocco ricevuto è stato
|
||
|
|
preso come base e l'estrazione aspetta il blocco successivo. Payload
|
||
|
|
`{"baseline_height"}`. `user_id` no, `round_id` sì. Transazione propria
|
||
|
|
([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza
|
||
|
|
un'altezza nota), vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-26]** `draw_header_corroboration_failed` — il blocco candidato per
|
||
|
|
l'estrazione non è stato confermato dalla maggioranza degli altri server
|
||
|
|
Electrum (o nessuno ha risposto); l'estrazione aspetta un blocco ulteriore.
|
||
|
|
Una riga per ogni blocco candidato rifiutato. Payload
|
||
|
|
`{"height", "reported_hash"}`. `user_id` no, `round_id` sì. Transazione
|
||
|
|
propria ([AUD-03]).
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-27]** `draw_stalled` — l'estrazione aspetta un nuovo blocco da più di
|
||
|
|
360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`), contati da
|
||
|
|
`drawing_started_at`; la riga si ripete ogni 360 secondi finché l'attesa
|
||
|
|
continua. Payload `{"tip_at_close", "current_tip_height",
|
||
|
|
"elapsed_seconds"}`. Nota: `tip_at_close` è la base *attuale* dell'attesa, che
|
||
|
|
dopo un `draw_header_corroboration_failed` diventa l'altezza del candidato
|
||
|
|
rifiutato, non più quella della chiusura. `user_id` no, `round_id` sì.
|
||
|
|
Transazione propria ([AUD-03]). Non viene scritta mentre è in corso una
|
||
|
|
corroborazione, solo mentre si aspetta un blocco.
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito.
|
||
|
|
Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne
|
||
|
|
quando il motivo è l'assenza di connessione (vuoto); `round_id` sì.
|
||
|
|
Transazione propria e *best effort* ([AUD-03]); è l'orologio del nuovo
|
||
|
|
tentativo ([AUD-09]). Valori possibili di `reason`:
|
||
|
|
|
||
|
|
| `reason` | Causa |
|
||
|
|
|---|---|
|
||
|
|
| `electrum client not connected` | Nessuna connessione Electrum al momento del tentativo |
|
||
|
|
| `no fee_address configured` | `fee_address` vuoto nella configurazione |
|
||
|
|
| `winner user not found` | L'utente vincitore non esiste più nel database |
|
||
|
|
| `insufficient pool UTXOs` | Gli UTXO confermati e non riservati dell'indirizzo del montepremi non coprono l'importo |
|
||
|
|
| `too_many_inputs` | Servirebbero più di `MAX_PAYOUT_TX_INPUTS` (500) input |
|
||
|
|
| `winner_share_below_network_fee` | La quota del vincitore non copre la commissione di rete |
|
||
|
|
| `commission_below_dust_limit` | La quota della piattaforma è sotto il limite di polvere |
|
||
|
|
| `payout build failed` | Qualunque altro errore nella costruzione (per esempio un `fee_address` malformato) |
|
||
|
|
| `broadcast rejected` | La rete ha rifiutato la transazione; la riga `building` resta al riconciliatore |
|
||
|
|
|
||
|
|
Un tentativo saltato perché esiste già un pagamento `building` o `pending`
|
||
|
|
per il round **non** scrive `payout_failed`. Cosa fare per ogni motivo è
|
||
|
|
descritto in [operativita.md](operativita.md).
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||
|
|
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
- **[AUD-29]** `payout_sent` — la rete ha accettato la transazione di
|
||
|
|
pagamento. Payload `{"txid", "winner_sats", "commission_sats"}`: quanto
|
||
|
|
riceve il vincitore (già al netto della commissione di rete) e quanto va a
|
||
|
|
`fee_address`. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
|
||
|
|
che porta la transazione pendente da `building` a `pending`.
|
||
|
|
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
|
|
*Test:* `tests/unit/test_scheduler.py`
|
||
|
|
|
||
|
|
### Riconciliazione
|
||
|
|
|
||
|
|
- **[AUD-30]** `pending_tx_recovered` — una transazione rimasta `building`
|
||
|
|
(tipicamente per un riavvio a metà invio) risulta invece presente in catena
|
||
|
|
ed è stata promossa a `pending`. Payload
|
||
|
|
`{"pending_transaction_id", "kind", "txid"}`. `user_id` e `round_id`
|
||
|
|
dipendono dal tipo: sono quelli della riga `pending_transactions` (per un
|
||
|
|
pagamento `user_id` è vuoto, per un prelievo `round_id` è vuoto). Stessa
|
||
|
|
transazione della promozione. Vedi
|
||
|
|
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||
|
|
*Codice:* `app/tx/reconcile.py:_promote` ·
|
||
|
|
*Test:* nessun test verifica la riga di audit.
|
||
|
|
|
||
|
|
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
|
||
|
|
non compare nella cronologia del proprio indirizzo ed è stata dichiarata
|
||
|
|
persa: stato `failed`, UTXO liberati, partecipante rimosso o prelievo
|
||
|
|
`failed`, `payout_txid` del round azzerato. Payload
|
||
|
|
`{"pending_transaction_id", "kind", "txid", "reason", "utxos_released"}`, con
|
||
|
|
`reason` oggi sempre `"not found on chain"` e `utxos_released` il numero di
|
||
|
|
UTXO liberati (sempre 0 per un pagamento, perché l'indirizzo del montepremi
|
||
|
|
non è tracciato in `utxo_events`). `user_id` e `round_id` come in [AUD-30].
|
||
|
|
Stessa transazione dell'abbandono.
|
||
|
|
*Codice:* `app/tx/reconcile.py:_abandon` ·
|
||
|
|
*Test:* `tests/unit/test_reconcile.py`
|
||
|
|
|
||
|
|
## Cosa non viene registrato
|
||
|
|
|
||
|
|
- **[AUD-32]** Questi fatti **non** producono una riga di audit; restano
|
||
|
|
ricostruibili solo dallo stato delle tabelle o dal log applicativo:
|
||
|
|
- apertura di un round;
|
||
|
|
- passaggi di stato del round a `closing` e `drawing`, e la chiusura dopo la
|
||
|
|
conferma del pagamento (`_on_payout_confirmed`);
|
||
|
|
- conferma di una scommessa, di un prelievo o di un pagamento
|
||
|
|
(`ConfirmationPoller`);
|
||
|
|
- ogni aumento di commissione RBF e ogni RBF impossibile (solo log);
|
||
|
|
- registrazione, login, login falliti e blocchi per troppi tentativi;
|
||
|
|
- cambio password fatto dall'utente (`POST /users/me/change-password`);
|
||
|
|
- invio di una segnalazione di bug;
|
||
|
|
- accesso in lettura al pannello admin (elenchi utenti, round, audit).
|
||
|
|
*Codice:* `app/rounds/service.py:open_new_round_if_needed`,
|
||
|
|
`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`
|
||
|
|
|
||
|
|
## Log applicativo
|
||
|
|
|
||
|
|
Il registro di audit è nel database e si vede da `/admin`; il log applicativo
|
||
|
|
è un file di testo e contiene molto di più (errori, riconnessioni, attese), ma
|
||
|
|
va letto sul server.
|
||
|
|
|
||
|
|
- **[AUD-33]** All'importazione di `app.main`, `setup_logging` aggiunge al
|
||
|
|
logger radice (livello `INFO`) un `RotatingFileHandler` su `logs/app.log`,
|
||
|
|
relativo alla radice del progetto: 10 MB per file, 5 file di rotazione
|
||
|
|
(`app.log.1` … `app.log.5`), formato
|
||
|
|
`data ora LIVELLO [logger] messaggio`. Lo stesso handler è aggiunto a
|
||
|
|
`uvicorn.error` e `uvicorn.access`, che non propagano al radice. Nel
|
||
|
|
container la radice del progetto è `/app`, e `/app/logs` è montato su
|
||
|
|
`./data/logs` dell'host.
|
||
|
|
*Codice:* `app/logging_config.py:setup_logging`, `docker-compose.yml`
|
||
|
|
|
||
|
|
- **[AUD-34]** Il logger radice ha **solo** l'handler su file: i messaggi dei
|
||
|
|
moduli dell'applicazione (scheduler, listener, riconciliatori) finiscono in
|
||
|
|
`data/logs/app.log` e non sull'output del container. `docker compose logs app`
|
||
|
|
mostra solo ciò che uvicorn scrive con la propria configurazione (avvio,
|
||
|
|
richieste) e l'output di avvio del container (controllo della chiave,
|
||
|
|
migrazioni Alembic).
|
||
|
|
*Codice:* `app/logging_config.py:setup_logging`
|
||
|
|
|
||
|
|
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
|
||
|
|
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
|
||
|
|
arriva solo un errore generico `internal_error` con stato 500, senza il testo
|
||
|
|
dell'eccezione.
|
||
|
|
*Codice:* `app/main.py:log_unhandled_exception`
|
||
|
|
|
||
|
|
- **[AUD-36]** Le richieste di polling delle due interfacce (per esempio
|
||
|
|
`/rounds/current` ogni 15 secondi per ogni pagina aperta, più un aggiornamento
|
||
|
|
a ogni evento SSE) vengono tutte registrate da `uvicorn.access`. Con 60 MB di spazio complessivo, la rotazione può cancellare
|
||
|
|
in poche ore o giorni i messaggi più vecchi: il log non è un archivio, il
|
||
|
|
registro di audit sì.
|
||
|
|
*Codice:* `app/logging_config.py:setup_logging`
|
||
|
|
|
||
|
|
### Messaggi di log che contano
|
||
|
|
|
||
|
|
Messaggi non accompagnati da una riga di audit ma utili in esercizio. Il
|
||
|
|
nome tra parentesi quadre nel log è il modulo (`app.rounds.scheduler`, ecc.).
|
||
|
|
|
||
|
|
| Livello | Modulo | Messaggio (inizio) | Significato |
|
||
|
|
|---|---|---|---|
|
||
|
|
| WARNING | `app.electrum.listener` | `all N Electrum server(s) unreachable, retrying in Ns` | Tutti i server provati hanno fallito; attesa da 1 a 30s prima del giro successivo |
|
||
|
|
| ERROR (con traccia) | `app.electrum.listener` | `Electrum session on … failed` | Errore nell'aprire la sessione: connessione, primo header, primo header rifiutato |
|
||
|
|
| INFO | `app.electrum.listener` | `Electrum connected to …` / `Electrum connection to … ended, reconnecting` | Connessione aperta / chiusa normalmente |
|
||
|
|
| WARNING | `app.electrum.listener` | `Electrum session on … ending: …` | Motivo della fine di una sessione già aperta (ping scaduto, socket chiuso, header successivo rifiutato con `HeaderValidationError`) |
|
||
|
|
| WARNING | `app.electrum.listener` | `ignoring Electrum header at height …` / `ignoring a competing header …` | Header scartato senza chiudere la sessione (senza hex, più basso, concorrente) |
|
||
|
|
| WARNING | `app.electrum.listener` | `could not corroborate … with any of N other configured server(s)` | Nessun altro server ha risposto: la corroborazione fallisce |
|
||
|
|
| WARNING | `app.electrum.listener` | `N new UTXO(s) for user_id=… not corroborated by the other servers` | Deposito non ancora accreditato; si riprova al prossimo aggiornamento |
|
||
|
|
| WARNING | `app.electrum.listener` | `N UTXO(s) spent outside the platform for user_id=…` | Accompagna `utxo_spent_externally` |
|
||
|
|
| WARNING | `app.electrum.listener` | `could not subscribe a newly-registered user's address` / `resubscribing all users failed partway through` | Sottoscrizione persa; la recupera `DepositReconciler` entro 300s |
|
||
|
|
| ERROR (con traccia) | `app.electrum.listener` | `failed to resubscribe user_id=…` | Un singolo utente non risottoscritto alla riconnessione |
|
||
|
|
| WARNING | `app.deposits.service` | `listunspent for user_id=… returned no entries at all …` | Risposta vuota per un indirizzo con fondi: ignorata come incompleta |
|
||
|
|
| ERROR (con traccia) | `app.deposits.reconcile` | `deposit reconciliation failed for user_id=…` | Il controllo periodico di un utente è fallito |
|
||
|
|
| ERROR (con traccia) | `app.rounds.scheduler` | `round scheduler tick failed` | Eccezione in un ciclo dello scheduler. Se avviene durante `drawing`, il round resta bloccato (vedi [operativita.md](operativita.md)) |
|
||
|
|
| INFO | `app.rounds.scheduler` | `round N: N bet(s) still in flight at close time, waiting` | Chiusura rinviata per scommesse ancora in volo |
|
||
|
|
| INFO | `app.rounds.scheduler` | `round N: still waiting for a block past height …` | Estrazione in attesa, ogni 60s |
|
||
|
|
| ERROR | `app.rounds.scheduler` | `round N: block … header … could not be corroborated …` | Accompagna `draw_header_corroboration_failed` |
|
||
|
|
| WARNING | `app.rounds.scheduler` | `round N: chain tip was unknown at closing time …` | Accompagna `draw_baseline_tip_unknown` |
|
||
|
|
| ERROR | `app.rounds.scheduler` | `round N payout blocked: …` / `payout deferred: not connected` / `payout failed: …` / `payout build failed` / `payout broadcast failed` | Accompagnano i vari `payout_failed` |
|
||
|
|
| INFO | `app.rounds.scheduler` | `round N payout already in flight (pending_transaction N), skipping` | Pagamento già in volo. Mentre aspetta la conferma, ricompare a ogni ciclo (ogni 5s) |
|
||
|
|
| ERROR (con traccia) | `app.rounds.scheduler` | `could not record the payout failure of round N` | Il `payout_failed` stesso non è stato salvato |
|
||
|
|
| ERROR | `app.rounds.service` | `invariant violated: N rounds are active at once` | Più di un round attivo: non dovrebbe mai accadere |
|
||
|
|
| ERROR | `app.rounds.service` | `could not open a round after 3 attempts` | Conflitti ripetuti all'apertura di un round |
|
||
|
|
| ERROR (con traccia) | `app.tx.broadcast` | `could not bump pending_transaction N` | RBF impossibile (resto insufficiente o commissione già al massimo). Si ripete ogni 30s finché la transazione conferma o viene abbandonata |
|
||
|
|
| INFO | `app.tx.broadcast` | `bumped <tipo> pending_transaction N: <vecchio> -> <nuovo>` | RBF riuscito: l'unica traccia di un aumento di commissione |
|
||
|
|
| WARNING | `app.tx.confirmation` | `could not fetch history for pending_transaction …` | Controllo di conferma non riuscito, si riprova tra 10s |
|
||
|
|
| WARNING | `app.tx.reconcile` | `could not check pending_transaction … against the chain` | Il riconciliatore non ha potuto decidere; non abbandona nulla |
|
||
|
|
| WARNING | `app.tx.reconcile` | `abandoned <tipo> pending_transaction …` | Accompagna `pending_tx_abandoned` |
|
||
|
|
| ERROR | `app.tx.reconcile` | `round N payout tx … vanished — round needs operator attention` | Pagamento abbandonato; il nuovo tentativo automatico parte da solo |
|
||
|
|
| ERROR | `app.auth.security` | `stored password hash is unparseable …` | Hash di password corrotto nel database: quell'utente non può accedere |
|
||
|
|
| ERROR (con traccia) | `app.main` | `Unhandled error on …` | Errore 500 su una richiesta ([AUD-35]) |
|