The two Mermaid diagrams under flowchart/ described the intended flow but had drifted from the code and carried no link back to it. docs/ now holds the specification instead: 15 Italian documents (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history) with 472 numbered requirements, each pointing at the function that implements it and the test that covers it. docs/processo-di-modifica.md defines the ID convention and the spec -> code -> test change workflow, and CLAUDE.md now names docs/ as authoritative. Writing the spec from the code also corrected stale claims in CLAUDE.md (B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control scope, the error contract's bare-string exceptions) and in the operator guides (payouts do retry, only `drawing` fails to resume, rounds do not open without fee_address). The B-70 doc test now pins the prose in docs/scommesse-e-prelievi.md instead of the deleted diagram. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
444 lines
26 KiB
Markdown
444 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]) |
|