Files
plm-lottery/docs/audit-log.md
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

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

43 KiB
Raw 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 Stato: da implementare (B-90, B-96), per due degli eventi nuovi · Oggi: gli eventi osservativi sono i primi quattro, chain_reorg_detected e rbf_bump_failed.

  • [AUD-04] Il payload è un dizionario serializzato in JSON nella colonna payload_json. created_at è impostato dall'applicazione, non dal 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 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-96), per l'ultima lettura · Oggi: le letture sono quelle di payout_failed, di draw_baseline_tip_unknown e di rbf_bump_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.

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 Stato: da implementare (B-90) · Oggi: scritto da POST /admin/pause (operator), dal riconciliatore (resurrected_tx), dal listener (reorg) e dallo scheduler (payout_blocked); l'avvio con START_PAUSED non lo scrive ancora.

  • [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: — 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, 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, 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/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). 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: — 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, 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: 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): 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 Stato: da implementare (B-92, B-94), per i due fatti che escono dall'elenco · Oggi: 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, 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 <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])