Files
davideandClaude Sonnet 5.5 575f1a5354 Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00

42 KiB
Raw Permalink Blame History

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).

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; le procedure che partono da un evento (per esempio un payout_failed) sono in 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_draw_block, app/rounds/scheduler.py:RoundScheduler._close_and_draw, 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._record_reorg

  • [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-37), che ne è il proprietario. 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] 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, tests/unit/test_state_consistency.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. Nel diagramma, gli eventi marcati con * sono da implementare.

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": <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, 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

  • [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-32). reason è la causa di questa pausa, con uno di questi valori, gli stessi di paused_reason (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, 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._record_reorg, app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/tx/reconcile.py:reconcile_once · Test: tests/unit/test_pause_reason.py, tests/unit/test_electrum_listener.py, tests/unit/test_restore_gap.py

  • [AUD-12] lottery_resumed — POST /admin/resume. Payload {"reason"}: il paused_reason che la ripresa ha tolto (null se la 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: tests/unit/test_pause_reason.py, tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain

  • [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). 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, 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: tests/unit/test_observability.py

  • [AUD-42] password_changed — un utente ha cambiato la propria password con POST /users/me/change-password (registrazione-e-depositi.md, 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: —

  • [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, 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 (OPS-28). Codice: app/electrum/listener.py:ElectrumListener._check_restore_gap · Test: tests/unit/test_restore_gap.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). 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, 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, tests/unit/test_confirmation_proof.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 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, 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, tests/unit/test_broadcast_outcome.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). 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: tests/unit/test_broadcast_outcome.py

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; 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: tests/unit/test_state_consistency.py

  • [AUD-37] draw_committed — il round è entrato in drawing: elenco dei partecipanti e altezza del blocco di estrazione sono fissati (ciclo-di-vita-del-round.md, 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: tests/unit/test_scheduler.py

  • [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. user_id sì (il vincitore), round_id sì. Stessa transazione che salva il vincitore sul round e lo porta a paying_out (RND-18): server_seed non può quindi comparire nel registro prima che il vincitore sia salvato. Codice: app/rounds/scheduler.py:RoundScheduler._complete_draw · Test: tests/unit/test_draw_proof.py

  • [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). 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 · Test: tests/unit/test_scheduler.py

  • [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); 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_draw_block · Test: tests/unit/test_scheduler.py

  • [AUD-27] draw_stalled — l'estrazione è in stallo secondo la regola di RND-20, 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_draw_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, 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; la verifica senza quorum di RND-39 termina anche lei con payout_failed, motivo pool verification unavailable. Cosa fare per ogni motivo è descritto in 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, 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, app/rounds/scheduler.py:RoundScheduler._verify_round_pool · Test: tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there, tests/unit/test_pool_verification.py::test_several_bets_gone_at_once_each_get_a_row_in_one_transaction

  • [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, 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 (OPS-30). Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/rounds/scheduler.py:RoundScheduler._verify_round_pool · Test: tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery, tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats

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). Codice: app/tx/reconcile.py:_promote · Test: tests/unit/test_state_consistency.py

  • [AUD-31] pending_tx_abandoned — una transazione building o pending è stata dichiarata persa con una prova (TX-17): 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, 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, tests/unit/test_reconcile_proof.py

  • [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). 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 (OPS-30). Codice: app/tx/reconcile.py:_resurrect · Test: tests/unit/test_reconcile_proof.py

  • [AUD-40] rbf_bump_failed — un aumento di commissione non è riuscito (TX-24). 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._record_failure, app/tx/broadcast.py:RbfBumper._tick, app/tx/broadcast.py:bump_fee · Test: tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason, tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported, tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone, tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row

Catena

  • [AUD-38] chain_reorg_detected — il listener ha seguito una riorganizzazione della catena (ELC-24). 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._record_reorg · Test: tests/unit/test_electrum_listener.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;
    • 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 <tipo> 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

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, che ne è il proprietario. Codice: app/logging_config.py:setup_logging · Test: tests/unit/test_observability.py

  • [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)
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])