poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
44 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_next_block,app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip,app/rounds/scheduler.py:RoundScheduler._log_payout_failure,app/tx/broadcast.py:RbfBumper._tick,app/main.py:lifespan,app/rounds/scheduler.py:RoundScheduler._tick,app/electrum/listener.py:ElectrumListener._apply_headerStato: da implementare (B-85, B-87, B-90, B-96), per i quattro eventi nuovi · Oggi: gli eventi osservativi sono solo i primi quattro. -
[AUD-04] Il
payloadè un dizionario serializzato in JSON nella 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.pyStato: da implementare (B-92), per filtri e paginazione · Oggi: accetta sololimit; le righe più vecchie delle ultime 500 si leggono solo direttamente dal database. -
[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(solopayout_failed) Stato: da implementare (B-87, B-96, B-73), per le ultime tre letture · Oggi: l'unica lettura è quella dipayout_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— 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.pyStato: da implementare (B-73, B-82, B-95), per gli ultimi tre campi · Oggi: i campi sono i primi sette. -
[AUD-11]
lottery_paused— la lotteria è stata messa in pausa, da chiunque. Payload{"reason"}, più"note"quando l'operatore l'ha indicata nel corpo 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._apply_header,app/rounds/scheduler.py:RoundScheduler._trigger_payout,app/tx/reconcile.py:reconcile_once· Test:tests/unit/test_pause_reason.pyStato: da implementare (B-82, B-85, B-90) · Oggi: scritto daPOST /admin/pause(operator) e dal riconciliatore (resurrected_tx);reorg,payout_blockede l'avvio conSTART_PAUSEDnon lo scrivono ancora. -
[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.pyStato: da implementare (B-82), per l'azzeramento dipayout_blocked_at· Oggi:rounds.payout_blocked_atnon esiste ancora. -
[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: — Stato: da implementare (B-92) · Oggi: la registrazione non scrive alcun evento. -
[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: — 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 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/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_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: — Stato: da implementare (B-96) · Oggi: l'attesa inclosingnon scrive alcun evento. -
[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: — Stato: da implementare (B-73) · Oggi: l'ingresso indrawingnon scrive alcun evento. -
[AUD-24]
winner_drawn— il vincitore è stato estratto e salvato; in questa riga il segreto del round viene rivelato. Payload{"winner_address", "pool_amount_sats", "block_height", "block_hash", "participants", "draw_height", "participants_hash", "server_seed", "seed"}: indirizzo del vincitore, montepremi totale (somma delle puntate nette), altezza e hash del blocco usato, numero di partecipanti, altezza del blocco di estrazione fissata all'ingresso 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._close_and_draw· Test:tests/unit/test_scheduler.pyStato: da implementare (B-73), per le ultime quattro chiavi · Oggi: il payload ha solo le prime cinque; il round non ha segreto né impronta dei partecipanti. -
[AUD-25]
draw_baseline_tip_unknown— quando il round sarebbe pronto 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,app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip· Test:tests/unit/test_scheduler.py(solo il comportamento attuale) Stato: da implementare (B-73) · Oggi: il round entra comunque indrawing, il primo blocco ricevuto è preso come base e l'estrazione aspetta il blocco successivo; payload{"baseline_height"}con l'altezza adottata. -
[AUD-26]
draw_header_corroboration_failed— raggiunte le conferme richieste, il quorum dei server non concorda sull'header all'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_next_block· Test:tests/unit/test_scheduler.py(solo il comportamento attuale) Stato: da implementare (B-73) · Oggi: una riga per ogni blocco candidato rifiutato, conheightl'altezza di quel candidato; l'estrazione aspetta poi un blocco ulteriore invece di richiedere la stessa altezza. -
[AUD-27]
draw_stalled— l'estrazione è in stallo secondo la regola di RND-20, 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_next_block· Test:tests/unit/test_scheduler.py(solo il comportamento attuale) Stato: da implementare (B-73) · Oggi: scritto quando l'attesa di un nuovo blocco supera 360 secondi contati dadrawing_started_at, e mai durante una corroborazione; payload{"tip_at_close", "current_tip_height", "elapsed_seconds"}, dovetip_at_closeè la base attuale dell'attesa, che dopo undraw_header_corroboration_faileddiventa l'altezza del candidato rifiutato. -
[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(B-82). 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· Test: — Stato: da implementare (B-82) · Oggi: nessun partecipante viene mai escluso; il pagamento fallisce coninsufficient pool UTXOs. -
[AUD-44]
payout_blocked— la verifica prima del pagamento ha trovato assente la scommessa del vincitore: il pagamento non si costruisce, il round resta 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· Test: — Stato: da implementare (B-82) · Oggi: il pagamento fallisce coninsufficient pool UTXOsa ogni tentativo, senza pausa.
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: nessun test verifica la riga di audit. Stato: da implementare (B-96), per il secondo evento · Oggi: si scrive solopending_tx_recovered. -
[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._tick,app/tx/broadcast.py:bump_fee· Test: — Stato: da implementare (B-87) · Oggi: l'errore va solo nel log (could not bump pending_transaction N), ogni 30 secondi.
Catena
- [AUD-38]
chain_reorg_detected— il listener ha seguito una riorganizzazione della catena (ELC-24). 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._apply_header· Test: — Stato: da implementare (B-85) · Oggi: nessuna riorganizzazione è rilevata; l'evento non esiste.
Cosa non viene registrato
-
[AUD-32] Questi fatti non producono una riga di audit; restano ricostruibili solo dallo stato delle tabelle o dal log applicativo:
- apertura di un round;
- passaggio di stato del round a
closing, e la chiusura dopo la conferma del pagamento (_on_payout_confirmed); - conferma di una scommessa, di un prelievo o di un pagamento
(
ConfirmationPoller); - ogni aumento di commissione RBF riuscito (solo il log
bumped <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_feeStato: da implementare (B-73, B-87, B-92, B-94), per i quattro fatti che escono dall'elenco · Oggi: anche l'ingresso indrawing, gli RBF impossibili, la registrazione e il cambio password riuscito non producono alcuna riga.
Log applicativo
Il registro di audit è nel database e si vede da /admin; il log applicativo
è un file di testo e contiene molto di più (errori, riconnessioni, attese), ma
va letto sul server.
-
[AUD-33] All'importazione di
app.main,setup_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_loggingStato: da implementare (B-92) · Oggi: il logger radice ha solo l'handler su file:docker compose logs appmostra 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 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]) |