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>
43 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_reorgStato: da implementare (B-90, B-96), per due degli eventi nuovi · Oggi: gli eventi osservativi sono i primi quattro,chain_reorg_detectederbf_bump_failed. -
[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-96), per l'ultima lettura · Oggi: le letture sono quelle dipayout_failed, didraw_baseline_tip_unknowne dirbf_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— 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.pyStato: da implementare (B-90) · Oggi: scritto daPOST /admin/pause(operator), dal riconciliatore (resurrected_tx), dal listener (reorg) e dallo scheduler (payout_blocked); l'avvio conSTART_PAUSEDnon lo scrive 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.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: — 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: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: 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._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_feeStato: 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_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]) |