# 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]** Alcuni eventi sono **solo osservativi**: non accompagnano un cambiamento di stato del database e vengono salvati in una transazione propria, aperta apposta. Sono `draw_baseline_tip_unknown`, `draw_header_corroboration_failed`, `draw_stalled`, `payout_failed`, `rbf_bump_failed` ([AUD-40]), `restore_gap_detected` ([AUD-43]), `closing_stalled` ([AUD-46]) e `chain_reorg_detected` di profondità 1 ([AUD-38]; con profondità ≥ 2 accompagna la pausa e ne condivide la transazione). 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`, `app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`, `app/rounds/scheduler.py:RoundScheduler._tick`, `app/electrum/listener.py:ElectrumListener._apply_header` *Stato:* da implementare (B-85, B-87, B-90, B-96), per i quattro eventi nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro. - **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna `payload_json`. `created_at` è impostato dall'applicazione, non dal database: è il default ORM `utcnow` della colonna (`datetime.now(timezone.utc)`, valutato quando la riga viene inserita), senza alcun `server_default`; una riga inserita a mano nel database senza `created_at` è quindi rifiutata (colonna non nulla). `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:utcnow`, `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. I tipi aggiunti dalle correzioni in corso ([AUD-37] … [AUD-46]) lo rispettano: il più lungo, `participant_reorged_out`, ha 23 caratteri. *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), al massimo `limit` per richiesta (predefinito 200, minimo 1, massimo 500), filtrabili per tipo di evento, `round_id`, `user_id` e intervallo di `created_at`, e a pagine (`before_id`), così che ogni riga del registro sia raggiungibile dall'API. Ogni elemento contiene `id`, `event_type`, `payload` (già decodificato da JSON), `user_id`, `round_id` e `created_at` in ISO 8601 UTC. Parametri, combinazione dei filtri ed errori sono definiti in [api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario. *Codice:* `app/api/routes/admin.py:list_audit_log` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta solo `limit`; le righe più vecchie delle ultime 500 si leggono solo direttamente dal database. - **[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]** Le logiche del programma che rileggono il registro sono esattamente queste, e nessun'altra decisione dipende da esso: - `_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; - l'RBF cerca, prima di scrivere `rbf_bump_failed`, una riga dello stesso tipo per la stessa transazione con lo stesso txid corrente e lo stesso motivo; se esiste non ne scrive un'altra ([AUD-40]); - lo scheduler usa la riga `closing_stalled` più recente del round come orologio delle ripetizioni ([AUD-46]); - lo scheduler scrive `draw_baseline_tip_unknown` solo se il round non ne ha già una ([AUD-25]). Leggere il registro, invece di tenere lo stato in memoria, fa sì che un riavvio non ripeta né anticipi queste righe. *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `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-87, B-96, B-73), per le ultime tre letture · *Oggi:* l'unica lettura è quella di `payout_failed`. ## 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. Nel diagramma, gli eventi marcati con * sono da implementare. ```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 Account["Account"] user_registered["user_registered *"] password_changed["password_changed *"] restore_gap_detected["restore_gap_detected *"] 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 closing_stalled["closing_stalled *"] draw_committed["draw_committed *"] winner_drawn draw_baseline_tip_unknown draw_header_corroboration_failed draw_stalled participant_reorged_out["participant_reorged_out *"] payout_blocked["payout_blocked *"] payout_failed payout_sent end subgraph Ricon["Riconciliazione"] pending_tx_recovered pending_tx_abandoned pending_tx_resurrected["pending_tx_resurrected *"] rbf_bump_failed["rbf_bump_failed *"] end subgraph Catena["Catena"] chain_reorg_detected["chain_reorg_detected *"] 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": , "to": }` (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`, `deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats`). 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` *Stato:* da implementare (B-73, B-82, B-95), per gli ultimi tre campi · *Oggi:* i campi sono i primi sette. - **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha indicata nel corpo di `POST /admin/pause` ([api.md](api.md#post-adminpause-e-post-adminresume), API-32). `reason` è la causa di **questa** pausa, con uno di questi valori, gli stessi di `paused_reason` ([modello-dati.md](modello-dati.md), DAT-69): | `reason` | Chi scrive la riga | Evento che la accompagna, nella stessa transazione | |---|---|---| | `operator` | `POST /admin/pause`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md), OPS-27) | — | | `reorg` | il listener Electrum, per una riorganizzazione di profondità ≥ 2 | `chain_reorg_detected` ([AUD-38]) | | `payout_blocked` | lo scheduler, quando la scommessa del vincitore non è più sulla catena | `payout_blocked` ([AUD-44]) | | `resurrected_tx` | il riconciliatore, quando una riga `failed` risulta confermata | `pending_tx_resurrected` ([AUD-39]) | Le pause automatiche (`reorg`, `payout_blocked`, `resurrected_tx`) sono scritte dal sistema, senza alcuna richiesta al pannello. Una pausa `operator` richiesta mentre è attivo un motivo automatico lascia `paused_reason` com'era (DAT-69), ma la riga riporta comunque `reason = "operator"`. `user_id` no, `round_id` no. La riga viene scritta a ogni pausa, anche se la lotteria era già in pausa. Stessa transazione di `paused = true`. *Codice:* `app/api/routes/admin.py:pause_lottery`, `app/main.py:lifespan`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/reconcile.py:reconcile_once` · *Test:* nessun test verifica la riga di audit. *Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* scritto solo da `POST /admin/pause`, con payload vuoto `{}`. - **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload `{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la lotteria non era in pausa), così il registro dice che cosa l'operatore ha accettato di riprendere (API-32). `user_id` no, `round_id` no. Scritto a ogni chiamata, anche se la lotteria non era in pausa. Stessa transazione di `paused = false`, `paused_reason = NULL` e dell'azzeramento di `payout_blocked_at`. *Codice:* `app/api/routes/admin.py:resume_lottery` · *Test:* nessun test verifica la riga di audit. *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* payload vuoto `{}`. - **[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 [limiti-noti.md](limiti-noti.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` ### Account - **[AUD-41]** `user_registered` — una registrazione è riuscita ([registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione), REG-16). Payload `{"derivation_index", "address"}`: l'indice di derivazione e l'indirizzo assegnati. Lo username non compare (è nella tabella `users`). `user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali indici erano già assegnati. Nessuna logica rilegge la riga. *Codice:* `app/auth/routes.py:register` · *Test:* — *Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun evento. - **[AUD-42]** `password_changed` — un utente ha cambiato la propria password con `POST /users/me/change-password` ([registrazione-e-depositi.md](registrazione-e-depositi.md#sessione-e-password), REG-12). Payload `{"user_id"}`; né la vecchia né la nuova password compaiono. `user_id` sì, `round_id` no. Stessa transazione del nuovo hash e dell'incremento di `token_version`. Un tentativo fallito (password attuale sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la riga. *Codice:* `app/api/routes/users.py:change_password` · *Test:* — *Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive alcun evento. - **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato cronologia sulla catena per almeno uno dei 20 indici di derivazione successivi al massimo assegnato, cioè il database è probabilmente un backup più vecchio della realtà ([registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione), REG-15). Payload `{"highest_assigned_index", "used"}`: `highest_assigned_index` è il massimo indice presente in `users` (`null` se non c'è alcun utente), `used` una lista di `{"derivation_index", "address"}` per ogni indirizzo con cronologia. `user_id` no, `round_id` no. Una riga per 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. ### 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, il quorum degli altri server lo ha confermato non speso con lo stesso importo ([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito), DEP-08) 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` *Stato:* da implementare (B-83), per la conferma del quorum · *Oggi:* basta la parola del server attivo. - **[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 **esplicitamente** la transazione di una scommessa; gli UTXO sono stati liberati e il partecipante rimosso. Un esito incerto (timeout, connessione caduta dopo l'invio) non scrive questo evento: la riga resta `building` per il riconciliatore ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede), B-76). 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` *Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore d'invio, compresi timeout e disconnessioni. - **[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 **esplicitamente** la transazione di un prelievo; gli UTXO sono stati liberati e il prelievo segnato `failed`. Come per [AUD-20], un esito incerto non scrive questo evento (B-76). 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. *Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore d'invio, compresi timeout e disconnessioni. ### 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-46]** `closing_stalled` — un round è in `closing` oltre la propria scadenza da più di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s) perché almeno una scommessa è ancora in volo. La regola, soglia e ripetizione comprese, è [TX-29](transazioni-e-riconciliazione.md#riconciliazione); qui c'è solo la voce del catalogo. Payload `{"in_flight", "txids", "seconds_past_deadline"}`: numero di scommesse in `building` o `broadcast`, lista dei loro txid 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. - **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei partecipanti e altezza del blocco di estrazione sono fissati ([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura), RND-14, RND-33). Payload `{"participants_hash", "draw_height", "participants", "server_seed_hash"}`: impronta dell'elenco ordinato, altezza del blocco di estrazione, numero di partecipanti confermati, impronta del segreto pubblicata all'apertura (RND-34). `server_seed` **non** compare. `user_id` no, `round_id` sì. Stessa transazione che porta il round a `drawing` e salva `draw_position`, `participants_hash` e `draw_height`. Poiché la riga è salvata prima che il blocco a `draw_height` esista, il registro prova che impronta e altezza erano fissate in anticipo. Nessuna logica rilegge la riga: la ripresa dell'estrazione (RND-36) legge i valori dal round. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* — *Stato:* da implementare (B-73) · *Oggi:* l'ingresso in `drawing` non scrive alcun evento. - **[AUD-24]** `winner_drawn` — il vincitore è stato estratto e salvato; in questa riga il segreto del round viene **rivelato**. Payload `{"winner_address", "pool_amount_sats", "block_height", "block_hash", "participants", "draw_height", "participants_hash", "server_seed", "seed"}`: indirizzo del vincitore, montepremi totale (somma delle puntate nette), altezza e hash del blocco usato, numero di partecipanti, altezza del blocco di estrazione fissata all'ingresso in `drawing` (uguale a `block_height`), impronta dei partecipanti (uguale a quella di `draw_committed`), `server_seed` in esadecimale (64 caratteri) e il seme calcolato, in esadecimale (64 caratteri, lo stesso valore di `seed_int`: come stringa, perché un intero di 256 bit perde precisione in JavaScript). Con `draw_committed` e la catena, la riga basta a rifare il calcolo di [RND-17](ciclo-di-vita-del-round.md#3--estrazione). `user_id` sì (il vincitore), `round_id` sì. Stessa transazione che salva il vincitore sul round e lo porta a `paying_out` ([RND-18](ciclo-di-vita-del-round.md#3--estrazione)): `server_seed` non può quindi comparire nel registro prima che il vincitore sia salvato. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` *Stato:* da implementare (B-73), per le ultime quattro chiavi · *Oggi:* il payload ha solo le prime cinque; il round non ha segreto né impronta dei partecipanti. - **[AUD-25]** `draw_baseline_tip_unknown` — quando il round sarebbe pronto per `drawing`, l'altezza della catena non era nota: il round resta in `closing` e il passaggio si ritenta al passo successivo ([RND-19](ciclo-di-vita-del-round.md#3--estrazione)). Payload vuoto `{}`. Una sola riga per round ([AUD-09]). `user_id` no, `round_id` sì. Transazione propria ([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza un'altezza nota). *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · *Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale) *Stato:* da implementare (B-73) · *Oggi:* il round entra comunque in `drawing`, il primo blocco ricevuto è preso come base e l'estrazione aspetta il blocco successivo; payload `{"baseline_height"}` con l'altezza adottata. - **[AUD-26]** `draw_header_corroboration_failed` — raggiunte le conferme richieste, il quorum dei server non concorda sull'header all'altezza `draw_height`, o non risponde abbastanza ([RND-16](ciclo-di-vita-del-round.md#3--estrazione)); al passo successivo si richiede la stessa altezza. Una riga per ogni tentativo fallito. Payload `{"height", "reported_hash"}`: `height` è sempre `draw_height`, `reported_hash` l'hash riportato dal server attivo. `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` (solo il comportamento attuale) *Stato:* da implementare (B-73) · *Oggi:* una riga per ogni blocco candidato rifiutato, con `height` l'altezza di quel candidato; l'estrazione aspetta poi un blocco ulteriore invece di richiedere la stessa altezza. - **[AUD-27]** `draw_stalled` — l'estrazione è in stallo secondo la regola di [RND-20](ciclo-di-vita-del-round.md#3--estrazione), che ne è il proprietario (soglia, condizioni e ripetizione); qui c'è solo la voce del catalogo. Payload `{"draw_height", "current_tip_height", "elapsed_seconds"}`: altezza del blocco di estrazione, altezza della catena nota in quel momento, secondi da quando dura la condizione di RND-20. `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` (solo il comportamento attuale) *Stato:* da implementare (B-73) · *Oggi:* scritto quando l'attesa di un nuovo blocco supera 360 secondi contati da `drawing_started_at`, e mai durante una corroborazione; payload `{"tip_at_close", "current_tip_height", "elapsed_seconds"}`, dove `tip_at_close` è la base *attuale* dell'attesa, che dopo un `draw_header_corroboration_failed` diventa l'altezza del candidato rifiutato. - **[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`, e nemmeno un pagamento trattenuto perché la scommessa del vincitore non è più sulla catena, che scrive invece `payout_blocked` ([AUD-44]). L'elenco dei motivi è di [RND-25](ciclo-di-vita-del-round.md#4--pagamento); la verifica senza quorum di [RND-39](ciclo-di-vita-del-round.md#4--pagamento) termina anche lei con `payout_failed`, motivo `pool verification unavailable` (B-82). 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`. Per un pagamento promosso dal riconciliatore vale [AUD-30]. *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* `tests/unit/test_scheduler.py` - **[AUD-45]** `participant_reorged_out` — la verifica prima del pagamento ha trovato assente, secondo il quorum, l'uscita della scommessa di un partecipante, che passa a `reorged_out` ed è escluso dal montepremi ([RND-39](ciclo-di-vita-del-round.md#4--pagamento), DAT-67). Una riga per partecipante. Payload `{"txid", "vout", "amount_sats", "pool_amount_sats"}`: l'uscita della scommessa sparita, il suo importo netto e il montepremi riscritto dopo l'esclusione. `user_id` sì (il partecipante), `round_id` sì. Stessa transazione del passaggio a `reorged_out` e della riscrittura di `pool_amount_sats`. Nessuna logica rilegge la riga. *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — *Stato:* da implementare (B-82) · *Oggi:* nessun partecipante viene mai escluso; il pagamento fallisce con `insufficient pool UTXOs`. - **[AUD-44]** `payout_blocked` — la verifica prima del pagamento ha trovato assente la scommessa **del vincitore**: il pagamento non si costruisce, il round resta in `paying_out` con `payout_blocked_at` e la lotteria va in pausa automatica ([RND-39](ciclo-di-vita-del-round.md#4--pagamento), DAT-68). Payload `{"txid", "vout", "amount_sats"}`: l'uscita della scommessa del vincitore. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione di `payout_blocked_at`, della pausa e della riga `lottery_paused` con `reason = "payout_blocked"` ([AUD-11]). Dopo `POST /admin/resume` la verifica si ripete: se la scommessa è ancora assente si scrive una nuova riga. Non è un `payout_failed` e non fa da orologio dei tentativi. Procedura in [operativita.md](operativita.md) (OPS-30). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — *Stato:* da implementare (B-82) · *Oggi:* il pagamento fallisce con `insufficient pool UTXOs` a ogni tentativo, senza pausa. ### 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). Nella stessa transazione della promozione si scrive anche l'evento che l'invio riuscito avrebbe scritto — `bet_placed` ([AUD-19]), `withdrawal_sent` ([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`. - **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending` è stata dichiarata persa **con una prova** ([TX-17](transazioni-e-riconciliazione.md#riconciliazione)): 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` uguale al `failure_reason` salvato sulla riga, che nomina la prova ([modello-dati.md](modello-dati.md), DAT-36), 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` *Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del server attivo non contenga il txid corrente, e `reason` è sempre `"not found on chain"`. - **[AUD-39]** `pending_tx_resurrected` — una riga `failed` da meno di 14 giorni è risultata confermata: una transazione data per persa è arrivata in un blocco ([TX-23](transazioni-e-riconciliazione.md#riconciliazione)). Payload `{"pending_transaction_id", "kind", "txid", "block_height"}`: `txid` è la versione confermata (una qualunque della catena di sostituzioni), `block_height` l'altezza del blocco che la contiene. `user_id` e `round_id` come in [AUD-30]. Una sola riga per transazione, perché la riga passa a `confirmed` e non viene più sorvegliata. Stessa transazione del passaggio a `confirmed`, della pausa e della riga `lottery_paused` con `reason = "resurrected_tx"` ([AUD-11]). Le righe collegate non vengono corrette: la procedura è in [operativita.md](operativita.md) (OPS-30). *Codice:* `app/tx/reconcile.py:reconcile_once` · *Test:* — *Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più guardata e l'evento non esiste. - **[AUD-40]** `rbf_bump_failed` — un aumento di commissione non è riuscito ([TX-24](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)). Payload `{"pending_transaction_id", "kind", "txid", "reason", "message"}`: `txid` è il txid corrente della riga; `reason` è uno di `change_too_small` (il resto non assorbe l'aumento, TX-11), `max_fee_rate` (la riga è già a `MAX_FEE_RATE_SAT_VB`) o `replacement_rejected` (la rete ha rifiutato esplicitamente la sostituzione); `message` è il testo dell'errore, troncato a 200 caratteri. `user_id` e `round_id` come in [AUD-30]. Transazione propria ([AUD-03]); la riga `pending` non cambia. Si scrive **una volta per coppia (txid corrente, `reason`)**, come fissato da TX-24: prima di scrivere si cerca nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un motivo diverso scrivono una riga nuova. *Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` · *Test:* — *Stato:* da implementare (B-87) · *Oggi:* l'errore va solo nel log (`could not bump pending_transaction N`), ogni 30 secondi. ### Catena - **[AUD-38]** `chain_reorg_detected` — il listener ha seguito una riorganizzazione della catena ([ELC-24](connessione-electrum.md#riorganizzazioni)). Payload `{"depth", "fork_height", "old_tip_height", "old_tip_hash", "new_tip_height", "new_tip_hash"}`: numero dei nostri blocchi sostituiti (`old_tip_height − fork_height`), altezza del punto di biforcazione, altezza e hash della punta prima e dopo. `user_id` no, `round_id` no. Con `depth` 1 è solo osservativo (transazione propria, [AUD-03]); con `depth` ≥ 2 è salvato nella stessa transazione della pausa e della riga `lottery_paused` con `reason = "reorg"` ([AUD-11]). Una biforcazione più profonda di 10 header non viene seguita e non scrive questo evento (la sessione si chiude). Nessuna logica rilegge la riga. *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · *Test:* — *Stato:* da implementare (B-85) · *Oggi:* nessuna riorganizzazione è rilevata; l'evento non esiste. ## 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; - passaggio di stato del round a `closing`, 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 **riuscito** (solo il log `bumped pending_transaction N`); - login, login falliti e blocchi per troppi tentativi, compresi i tentativi falliti di cambio password; - invio di una segnalazione di bug; - accesso in lettura al pannello admin (elenchi utenti, round, audit). Escono da questo elenco, perché diventano eventi del catalogo: l'ingresso in `drawing` (`draw_committed`, [AUD-37]), ogni RBF impossibile o rifiutato (`rbf_bump_failed`, [AUD-40]), la registrazione (`user_registered`, [AUD-41]) e il cambio password riuscito (`password_changed`, [AUD-42]). *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` *Stato:* da implementare (B-73, B-87, B-92, B-94), per i quattro fatti che escono dall'elenco · *Oggi:* anche l'ingresso in `drawing`, gli RBF impossibili, la registrazione e il cambio password riuscito non producono alcuna riga. ## 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]** I messaggi dei moduli dell'applicazione (scheduler, listener, riconciliatori) finiscono in `data/logs/app.log` **e** sull'output del container, quindi `docker compose logs app` li mostra insieme all'output di avvio del container (controllo della chiave, migrazioni Alembic). La regola è di [ARC-09](architettura.md#log), che ne è il proprietario. *Codice:* `app/logging_config.py:setup_logging` *Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo l'handler su file: `docker compose logs app` mostra solo l'output di avvio del container e ciò che uvicorn scrive con la propria configurazione (avvio, richieste). - **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata per intero nel log (`Unhandled error on `) 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 pending_transaction N: -> ` | 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 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]) |