- 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>
42 KiB
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 righeAuditLog. 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_loge poisession.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]) echain_reorg_detecteddi profondità 1 ([AUD-38]; con profondità ≥ 2 accompagna la pausa e ne condivide la transazione). Perpayout_failedla 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 colonnapayload_json.created_atè impostato dall'applicazione, non dal database: è il default ORMutcnowdella colonna (datetime.now(timezone.utc), valutato quando la riga viene inserita), senza alcunserver_default; una riga inserita a mano nel database senzacreated_atè quindi rifiutata (colonna non nulla).user_ideround_idsono 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 colonnaString(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 daX-Admin-Token) restituisce le righe dalla più recente (ordine periddecrescente), al massimolimitper richiesta (predefinito 200, minimo 1, massimo 500), filtrabili per tipo di evento,round_id,user_ide intervallo dicreated_at, e a pagine (before_id), così che ogni riga del registro sia raggiungibile dall'API. Ogni elemento contieneid,event_type,payload(già decodificato da JSON),user_id,round_idecreated_atin 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
/adminchiama 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_dueusa la rigapayout_failedpiù 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_stalledpiù recente del round come orologio delle ripetizioni ([AUD-46]); - lo scheduler scrive
draw_baseline_tip_unknownsolo 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— unPUT /admin/configha 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_idno,round_idno. 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 diPOST /admin/pause(api.md, API-32).reasonè la causa di questa pausa, con uno di questi valori, gli stessi dipaused_reason(modello-dati.md, DAT-69):reasonChi scrive la riga Evento che la accompagna, nella stessa transazione operatorPOST /admin/pause; l'avvio conSTART_PAUSED=true(operativita.md, OPS-27)— reorgil listener Electrum, per una riorganizzazione di profondità ≥ 2 chain_reorg_detected([AUD-38])payout_blockedlo scheduler, quando la scommessa del vincitore non è più sulla catena payout_blocked([AUD-44])resurrected_txil riconciliatore, quando una riga failedrisulta confermatapending_tx_resurrected([AUD-39])Le pause automatiche (
reorg,payout_blocked,resurrected_tx) sono scritte dal sistema, senza alcuna richiesta al pannello. Una pausaoperatorrichiesta mentre è attivo un motivo automatico lasciapaused_reasoncom'era (DAT-69), ma la riga riporta comunquereason = "operator".user_idno,round_idno. La riga viene scritta a ogni pausa, anche se la lotteria era già in pausa. Stessa transazione dipaused = 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"}: ilpaused_reasonche la ripresa ha tolto (nullse la lotteria non era in pausa), così il registro dice che cosa l'operatore ha accettato di riprendere (API-32).user_idno,round_idno. Scritto a ogni chiamata, anche se la lotteria non era in pausa. Stessa transazione dipaused = false,paused_reason = NULLe dell'azzeramento dipayout_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}/privkeyha esportato la chiave privata (WIF) di un utente. Payload{"user_id"}.user_idsì (l'utente la cui chiave è stata esportata),round_idno. 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.pycopre solo l'esportazione). -
[AUD-14]
admin_password_reset—POST /admin/users/{id}/reset-passwordha impostato una nuova password casuale. Payload{"user_id"}; la nuova password non compare.user_idsì,round_idno. Stessa transazione del nuovo hash e dell'incremento ditoken_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}/statusha cambiato lo stato di una segnalazione. Payload{"report_id", "from", "to"}con gli statiopen/read/resolved.user_iddipende: è l'autore della segnalazione, vuoto se era anonima;round_idno. 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 tabellausers).user_idsì (il nuovo utente),round_idno. 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 conPOST /users/me/change-password(registrazione-e-depositi.md, REG-12). Payload{"user_id"}; né la vecchia né la nuova password compaiono.user_idsì,round_idno. Stessa transazione del nuovo hash e dell'incremento ditoken_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 inusers(nullse non c'è alcun utente),useduna lista di{"derivation_index", "address"}per ogni indirizzo con cronologia.user_idno,round_idno. 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_idsì,round_idno. Stessa transazione dell'inserimento inutxo_eventse 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 undeposit_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_idsì,round_idno. 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_idsì,round_idno. 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_idsì,round_idsì. Stessa transazione che porta il partecipante abroadcaste la transazione pendente apending. 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 restabuildingper il riconciliatore (scommesse-e-prelievi.md, B-76). Payload{"txid", "reason"}, conreasonil testo dell'errore troncato a 200 caratteri.user_idsì,round_idno (a differenza dibet_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_idsì,round_idno. Stessa transazione che porta il prelievo abroadcast. 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 segnatofailed. Come per [AUD-20], un esito incerto non scrive questo evento (B-76). Payload{"txid", "reason"}(reasontroncato a 200 caratteri).user_idsì,round_idno. 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_idno,round_idsì. Stessa transazione del passaggio aclosed. È 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 è inclosingoltre la propria scadenza da più diCLOSING_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 inbuildingobroadcast, lista dei loro txid correnti, secondi trascorsi dalla scadenza.user_idno,round_idsì. 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 indrawing: 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_seednon compare.user_idno,round_idsì. Stessa transazione che porta il round adrawinge salvadraw_position,participants_hashedraw_height. Poiché la riga è salvata prima che il blocco adraw_heightesista, 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 indrawing(uguale ablock_height), impronta dei partecipanti (uguale a quella didraw_committed),server_seedin esadecimale (64 caratteri) e il seme calcolato, in esadecimale (64 caratteri, lo stesso valore diseed_int: come stringa, perché un intero di 256 bit perde precisione in JavaScript). Condraw_committede la catena, la riga basta a rifare il calcolo di RND-17.user_idsì (il vincitore),round_idsì. Stessa transazione che salva il vincitore sul round e lo porta apaying_out(RND-18):server_seednon 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 perdrawing, l'altezza della catena non era nota: il round resta inclosinge il passaggio si ritenta al passo successivo (RND-19). Payload vuoto{}. Una sola riga per round ([AUD-09]).user_idno,round_idsì. 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'altezzadraw_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è sempredraw_height,reported_hashl'hash riportato dal server attivo.user_idno,round_idsì. 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_idno,round_idsì. 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_iddipende: il vincitore, tranne quando il motivo è l'assenza di connessione (vuoto);round_idsì. Transazione propria e best effort ([AUD-03]); è l'orologio del nuovo tentativo ([AUD-09]). Valori possibili direason:reasonCausa electrum client not connectedNessuna connessione Electrum al momento del tentativo no fee_address configuredfee_addressvuoto nella configurazionewinner user not foundL'utente vincitore non esiste più nel database insufficient pool UTXOsGli UTXO confermati e non riservati dell'indirizzo del montepremi non coprono l'importo too_many_inputsServirebbero più di MAX_PAYOUT_TX_INPUTS(500) inputwinner_share_below_network_feeLa quota del vincitore non copre la commissione di rete commission_below_dust_limitLa quota della piattaforma è sotto il limite di polvere payout build failedQualunque altro errore nella costruzione (per esempio un fee_addressmalformato)broadcast rejectedLa rete ha rifiutato la transazione; la riga buildingresta al riconciliatoreUn tentativo saltato perché esiste già un pagamento
buildingopendingper il round non scrivepayout_failed, e nemmeno un pagamento trattenuto perché la scommessa del vincitore non è più sulla catena, che scrive invecepayout_blocked([AUD-44]). L'elenco dei motivi è di RND-25; la verifica senza quorum di RND-39 termina anche lei conpayout_failed, motivopool 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 afee_address.user_idsì (il vincitore),round_idsì. Stessa transazione che porta la transazione pendente dabuildingapending. 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 areorged_outed è 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_idsì (il partecipante),round_idsì. Stessa transazione del passaggio areorged_oute della riscrittura dipool_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 inpaying_outconpayout_blocked_ate la lotteria va in pausa automatica (RND-39, DAT-68). Payload{"txid", "vout", "amount_sats"}: l'uscita della scommessa del vincitore.user_idsì (il vincitore),round_idsì. Stessa transazione dipayout_blocked_at, della pausa e della rigalottery_pausedconreason = "payout_blocked"([AUD-11]). DopoPOST /admin/resumela verifica si ripete: se la scommessa è ancora assente si scrive una nuova riga. Non è unpayout_failede 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 rimastabuilding(tipicamente per un riavvio a metà invio) risulta invece presente in catena ed è stata promossa apending. Payload{"pending_transaction_id", "kind", "txid"}.user_ideround_iddipendono dal tipo: sono quelli della rigapending_transactions(per un pagamentouser_idè vuoto, per un prelievoround_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]) opayout_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 transazionebuildingopendingè stata dichiarata persa con una prova (TX-17): statofailed, UTXO liberati, partecipante rimosso o prelievofailed,payout_txiddel round azzerato. Payload{"pending_transaction_id", "kind", "txid", "reason", "utxos_released"}, conreasonuguale alfailure_reasonsalvato sulla riga, che nomina la prova (modello-dati.md, DAT-36), eutxos_releasedil numero di UTXO liberati (sempre 0 per un pagamento, perché l'indirizzo del montepremi non è tracciato inutxo_events).user_ideround_idcome 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 rigafailedda 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_heightl'altezza del blocco che la contiene.user_ideround_idcome in [AUD-30]. Una sola riga per transazione, perché la riga passa aconfirmede non viene più sorvegliata. Stessa transazione del passaggio aconfirmed, della pausa e della rigalottery_pausedconreason = "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 dichange_too_small(il resto non assorbe l'aumento, TX-11),max_fee_rate(la riga è già aMAX_FEE_RATE_SAT_VB) oreplacement_rejected(la rete ha rifiutato esplicitamente la sostituzione);messageè il testo dell'errore, troncato a 200 caratteri.user_ideround_idcome in [AUD-30]. Transazione propria ([AUD-03]); la rigapendingnon 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_idno,round_idno. Condepth1 è solo osservativo (transazione propria, [AUD-03]); condepth≥ 2 è salvato nella stessa transazione della pausa e della rigalottery_pausedconreason = "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_loggingaggiunge al logger radice (livelloINFO) unRotatingFileHandlersulogs/app.log, relativo alla radice del progetto: 10 MB per file, 5 file di rotazione (app.log.1…app.log.5), formatodata ora LIVELLO [logger] messaggio. Lo stesso handler è aggiunto auvicorn.erroreuvicorn.access, che non propagano al radice. Nel container la radice del progetto è/app, e/app/logsè montato su./data/logsdell'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.loge sull'output del container, quindidocker compose logs appli 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 genericointernal_errorcon 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/currentogni 15 secondi per ogni pagina aperta, più un aggiornamento a ogni evento SSE) vengono tutte registrate dauvicorn.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]) |