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>
716 lines
43 KiB
Markdown
716 lines
43 KiB
Markdown
# 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](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](modello-dati.md); le procedure che partono da un evento
|
||
(per esempio un `payout_failed`) sono in [operativita.md](operativita.md).
|
||
|
||
## Come si scrive
|
||
|
||
- **[AUD-01]** Ogni riga di audit nasce da un'unica funzione,
|
||
`write_audit_log(session, event_type, payload, user_id=None, round_id=None)`.
|
||
La funzione **non fa commit**: aggiunge la riga alla sessione del chiamante,
|
||
che la salva insieme al resto delle sue modifiche. Nessun altro punto del
|
||
codice crea righe `AuditLog`.
|
||
*Codice:* `app/audit/log.py:write_audit_log`
|
||
|
||
- **[AUD-02]** Per gli eventi che accompagnano un cambiamento di stato, la riga
|
||
di audit è salvata **nella stessa transazione** del cambiamento che descrive:
|
||
o si salvano entrambi, o nessuno dei due. Vale per tutti gli eventi del
|
||
catalogo tranne quelli elencati in [AUD-03].
|
||
*Codice:* i chiamanti elencati nel catalogo, ognuno dei quali chiama
|
||
`write_audit_log` e poi `session.commit()` sulla stessa sessione.
|
||
|
||
- **[AUD-03]** Alcuni eventi sono **solo osservativi**: non accompagnano un
|
||
cambiamento di stato del database e vengono salvati in una transazione
|
||
propria, aperta apposta. Sono `draw_baseline_tip_unknown`,
|
||
`draw_header_corroboration_failed`, `draw_stalled`, `payout_failed`,
|
||
`rbf_bump_failed` ([AUD-40]), `restore_gap_detected` ([AUD-43]),
|
||
`closing_stalled` ([AUD-46]) e `chain_reorg_detected` di profondità 1
|
||
([AUD-38]; con profondità ≥ 2 accompagna la pausa e ne condivide la
|
||
transazione). Per `payout_failed` la scrittura è anche *best effort*: se
|
||
fallisce, l'errore viene registrato nel log applicativo
|
||
(`could not record the payout failure of round …`) e ignorato.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`,
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`,
|
||
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure`,
|
||
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
|
||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||
`app/electrum/listener.py:ElectrumListener._record_reorg`
|
||
*Stato:* da implementare (B-90, B-96), per due degli eventi
|
||
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
|
||
`chain_reorg_detected` e `rbf_bump_failed`.
|
||
|
||
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
|
||
`payload_json`. `created_at` è impostato dall'applicazione, non dal
|
||
database: è il default ORM `utcnow` della colonna (`datetime.now(timezone.utc)`,
|
||
valutato quando la riga viene inserita), senza alcun `server_default`; una
|
||
riga inserita a mano nel database senza `created_at` è quindi rifiutata
|
||
(colonna non nulla). `user_id` e `round_id` sono chiavi esterne opzionali,
|
||
valorizzate come indicato evento per evento nel catalogo.
|
||
*Codice:* `app/audit/log.py:write_audit_log`, `app/db/models.py:utcnow`,
|
||
`app/db/models.py:AuditLog`
|
||
|
||
- **[AUD-05]** Il registro è **in sola aggiunta**: nessuna parte del codice
|
||
modifica o cancella una riga di `audit_log`, e non c'è alcuna rotazione o
|
||
scadenza. La tabella cresce per sempre.
|
||
*Codice:* `app/audit/log.py:write_audit_log` (unico punto di scrittura)
|
||
|
||
- **[AUD-06]** `event_type` è una colonna `String(32)`. Il nome più lungo oggi
|
||
in uso, `draw_header_corroboration_failed`, occupa esattamente 32 caratteri:
|
||
un nuovo tipo di evento deve restare entro questo limite. SQLite non lo fa
|
||
rispettare, un altro database lo farebbe. I tipi aggiunti dalle correzioni
|
||
in corso ([AUD-37] … [AUD-46]) lo rispettano: il più lungo,
|
||
`participant_reorged_out`, ha 23 caratteri.
|
||
*Codice:* `app/db/models.py:AuditLog`
|
||
|
||
## Come si legge
|
||
|
||
- **[AUD-07]** `GET /admin/audit-log` (protetto da `X-Admin-Token`) restituisce
|
||
le righe dalla più recente (ordine per `id` decrescente), al massimo `limit`
|
||
per richiesta (predefinito 200, minimo 1, massimo 500), filtrabili per tipo
|
||
di evento, `round_id`, `user_id` e intervallo di `created_at`, e a pagine
|
||
(`before_id`), così che ogni riga del registro sia raggiungibile dall'API.
|
||
Ogni elemento contiene `id`, `event_type`, `payload` (già decodificato da
|
||
JSON), `user_id`, `round_id` e `created_at` in ISO 8601 UTC. Parametri,
|
||
combinazione dei filtri ed errori sono definiti in
|
||
[api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario.
|
||
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
|
||
*Test:* `tests/unit/test_admin.py`
|
||
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
|
||
solo `limit`; le righe più vecchie delle ultime 500 si leggono solo
|
||
direttamente dal database.
|
||
|
||
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
|
||
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
|
||
sezione, e mostra id, tipo, payload JSON grezzo, utente, round e data. Non ha
|
||
filtri né ricerca.
|
||
*Codice:* `app/static/admin.js:loadAuditLog`
|
||
|
||
- **[AUD-09]** Le logiche del programma che rileggono il registro sono
|
||
esattamente queste, e nessun'altra decisione dipende da esso:
|
||
- `_retry_payout_if_due` usa la riga `payout_failed` più recente del round
|
||
come orologio per decidere se ritentare il pagamento. Se è più recente di
|
||
60 secondi (`_PAYOUT_RETRY_INTERVAL_SECONDS`) il tentativo viene saltato;
|
||
se non esiste, il tentativo parte subito;
|
||
- l'RBF cerca, prima di scrivere `rbf_bump_failed`, una riga dello stesso
|
||
tipo per la stessa transazione con lo stesso txid corrente e lo stesso
|
||
motivo; se esiste non ne scrive un'altra ([AUD-40]);
|
||
- lo scheduler usa la riga `closing_stalled` più recente del round come
|
||
orologio delle ripetizioni ([AUD-46]);
|
||
- lo scheduler scrive `draw_baseline_tip_unknown` solo se il round non ne
|
||
ha già una ([AUD-25]).
|
||
|
||
Leggere il registro, invece di tenere lo stato in memoria, fa sì che un
|
||
riavvio non ripeta né anticipi queste righe.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
|
||
`app/tx/broadcast.py:RbfBumper._tick`,
|
||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
|
||
*Stato:* da implementare (B-96), per l'ultima lettura ·
|
||
*Oggi:* le letture sono quelle di `payout_failed`, di
|
||
`draw_baseline_tip_unknown` e di `rbf_bump_failed`.
|
||
|
||
## Catalogo degli eventi
|
||
|
||
Legenda delle colonne `user_id` / `round_id`: **sì** = sempre valorizzato,
|
||
**no** = sempre vuoto, **dipende** = come spiegato nel testo. Gli importi sono
|
||
sempre in satoshi. Nel diagramma, gli eventi marcati con * sono da
|
||
implementare.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph Operatore["Pannello admin"]
|
||
config_updated
|
||
lottery_paused
|
||
lottery_resumed
|
||
admin_privkey_accessed
|
||
admin_password_reset
|
||
bug_report_status_changed
|
||
end
|
||
subgraph Account["Account"]
|
||
user_registered["user_registered *"]
|
||
password_changed["password_changed *"]
|
||
restore_gap_detected["restore_gap_detected *"]
|
||
end
|
||
subgraph Fondi["Denaro degli utenti"]
|
||
deposit_credited
|
||
utxo_spent_externally
|
||
utxo_external_spend_reinstated
|
||
bet_placed
|
||
bet_broadcast_failed
|
||
withdrawal_sent
|
||
withdrawal_broadcast_failed
|
||
end
|
||
subgraph Round["Round"]
|
||
round_closed
|
||
closing_stalled["closing_stalled *"]
|
||
draw_committed["draw_committed *"]
|
||
winner_drawn
|
||
draw_baseline_tip_unknown
|
||
draw_header_corroboration_failed
|
||
draw_stalled
|
||
participant_reorged_out["participant_reorged_out *"]
|
||
payout_blocked["payout_blocked *"]
|
||
payout_failed
|
||
payout_sent
|
||
end
|
||
subgraph Ricon["Riconciliazione"]
|
||
pending_tx_recovered
|
||
pending_tx_abandoned
|
||
pending_tx_resurrected["pending_tx_resurrected *"]
|
||
rbf_bump_failed["rbf_bump_failed *"]
|
||
end
|
||
subgraph Catena["Catena"]
|
||
chain_reorg_detected["chain_reorg_detected *"]
|
||
end
|
||
```
|
||
|
||
### Pannello admin
|
||
|
||
- **[AUD-10]** `config_updated` — un `PUT /admin/config` ha cambiato almeno un
|
||
parametro. Payload: un oggetto con una chiave per ogni campo **effettivamente
|
||
cambiato**, ciascuna `{"from": <valore precedente>, "to": <valore nuovo>}`
|
||
(i campi sono quelli di `_CONFIG_FIELDS`: `fee_address`, `bet_amount_sats`,
|
||
`round_duration_seconds`, `round_cooldown_seconds`, `fee_rate_sat_vb`,
|
||
`rbf_timeout_seconds`, `draw_animation_seconds`, `deposit_confirmations`,
|
||
`draw_confirmations`, `max_round_pool_sats`). Un campo inviato con lo
|
||
stesso valore che aveva non compare; se nulla cambia, non si scrive nessuna
|
||
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
|
||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||
*Test:* `tests/unit/test_admin.py`
|
||
|
||
- **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da
|
||
chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha
|
||
indicata nel corpo di `POST /admin/pause`
|
||
([api.md](api.md#post-adminpause-e-post-adminresume), API-32). `reason` è
|
||
la causa di **questa** pausa, con uno di questi valori, gli stessi di
|
||
`paused_reason` ([modello-dati.md](modello-dati.md), DAT-69):
|
||
|
||
| `reason` | Chi scrive la riga | Evento che la accompagna, nella stessa transazione |
|
||
|---|---|---|
|
||
| `operator` | `POST /admin/pause`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md), OPS-27) | — |
|
||
| `reorg` | il listener Electrum, per una riorganizzazione di profondità ≥ 2 | `chain_reorg_detected` ([AUD-38]) |
|
||
| `payout_blocked` | lo scheduler, quando la scommessa del vincitore non è più sulla catena | `payout_blocked` ([AUD-44]) |
|
||
| `resurrected_tx` | il riconciliatore, quando una riga `failed` risulta confermata | `pending_tx_resurrected` ([AUD-39]) |
|
||
|
||
Le pause automatiche (`reorg`, `payout_blocked`, `resurrected_tx`) sono
|
||
scritte dal sistema, senza alcuna richiesta al pannello. Una pausa
|
||
`operator` richiesta mentre è attivo un motivo automatico lascia
|
||
`paused_reason` com'era (DAT-69), ma la riga riporta comunque
|
||
`reason = "operator"`. `user_id` no, `round_id` no. La riga viene scritta a
|
||
ogni pausa, anche se la lotteria era già in pausa. Stessa transazione di
|
||
`paused = true`.
|
||
*Codice:* `app/api/routes/admin.py:pause_lottery`, `app/main.py:lifespan`,
|
||
`app/electrum/listener.py:ElectrumListener._record_reorg`,
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||
`app/tx/reconcile.py:reconcile_once` ·
|
||
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
|
||
*Stato:* da implementare (B-90) · *Oggi:* scritto da
|
||
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`), dal
|
||
listener (`reorg`) e dallo scheduler (`payout_blocked`); l'avvio con
|
||
`START_PAUSED` non lo scrive ancora.
|
||
|
||
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload
|
||
`{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la
|
||
lotteria non era in pausa), così il registro dice che cosa l'operatore ha
|
||
accettato di riprendere (API-32). `user_id` no, `round_id` no. Scritto a
|
||
ogni chiamata, anche se la lotteria non era in pausa. Stessa transazione di
|
||
`paused = false`, `paused_reason = NULL` e dell'azzeramento di
|
||
`payout_blocked_at`.
|
||
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
|
||
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
|
||
|
||
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
|
||
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
|
||
`user_id` sì (l'utente la cui chiave è stata esportata), `round_id` no. La
|
||
riga è salvata **prima** che la risposta con la chiave venga restituita: se
|
||
il salvataggio fallisce, la chiave non esce. Per un utente inesistente
|
||
(404) non si scrive nulla. Questa registrazione non va mai rimossa (vedi
|
||
[limiti-noti.md](limiti-noti.md)).
|
||
*Codice:* `app/api/routes/admin.py:user_privkey` ·
|
||
*Test:* nessun test verifica la riga di audit (`tests/unit/test_admin.py`
|
||
copre solo l'esportazione).
|
||
|
||
- **[AUD-14]** `admin_password_reset` — `POST /admin/users/{id}/reset-password`
|
||
ha impostato una nuova password casuale. Payload `{"user_id"}`; la nuova
|
||
password **non** compare. `user_id` sì, `round_id` no. Stessa transazione del
|
||
nuovo hash e dell'incremento di `token_version` (che invalida le sessioni
|
||
esistenti).
|
||
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
|
||
*Test:* nessun test verifica la riga di audit.
|
||
|
||
- **[AUD-15]** `bug_report_status_changed` — `POST /admin/bug-reports/{id}/status`
|
||
ha cambiato lo stato di una segnalazione. Payload
|
||
`{"report_id", "from", "to"}` con gli stati `open`/`read`/`resolved`.
|
||
`user_id` dipende: è l'autore della segnalazione, vuoto se era anonima;
|
||
`round_id` no. Se lo stato richiesto è quello già presente non si scrive
|
||
nulla. Stessa transazione del cambio di stato.
|
||
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
|
||
*Test:* `tests/unit/test_bug_reports.py`
|
||
|
||
### Account
|
||
|
||
- **[AUD-41]** `user_registered` — una registrazione è riuscita
|
||
([registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione),
|
||
REG-16). Payload `{"derivation_index", "address"}`: l'indice di derivazione
|
||
e l'indirizzo assegnati. Lo username non compare (è nella tabella `users`).
|
||
`user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea
|
||
l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali
|
||
indici erano già assegnati. Nessuna logica rilegge la riga.
|
||
*Codice:* `app/auth/routes.py:register` · *Test:* —
|
||
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
|
||
evento.
|
||
|
||
- **[AUD-42]** `password_changed` — un utente ha cambiato la propria password
|
||
con `POST /users/me/change-password`
|
||
([registrazione-e-depositi.md](registrazione-e-depositi.md#sessione-e-password),
|
||
REG-12). Payload `{"user_id"}`; né la vecchia né la nuova password
|
||
compaiono. `user_id` sì, `round_id` no. Stessa transazione del nuovo hash e
|
||
dell'incremento di `token_version`. Un tentativo fallito (password attuale
|
||
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
|
||
riga.
|
||
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
|
||
*Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive
|
||
alcun evento.
|
||
|
||
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
|
||
cronologia sulla catena per almeno uno dei 20 indici di derivazione
|
||
successivi al massimo assegnato, cioè il database è probabilmente un backup
|
||
più vecchio della realtà
|
||
([registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione),
|
||
REG-15). Payload `{"highest_assigned_index", "used"}`:
|
||
`highest_assigned_index` è il massimo indice presente in `users` (`null` se
|
||
non c'è alcun utente), `used` una lista di `{"derivation_index", "address"}`
|
||
per ogni indirizzo con cronologia. `user_id` no, `round_id` no. Una riga per
|
||
ogni avvio in cui il controllo trova indirizzi usati; transazione propria
|
||
([AUD-03]). La sospensione delle registrazioni che ne segue non dipende da
|
||
questa riga. Procedura in [operativita.md](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](registrazione-e-depositi.md)). Una riga
|
||
per UTXO. Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì,
|
||
`round_id` no. Stessa transazione dell'inserimento in `utxo_events` e del
|
||
ricalcolo del saldo. Attenzione: non riguarda solo i depositi dall'esterno.
|
||
Anche **le vincite** (l'uscita del pagamento verso il vincitore) e **i resti**
|
||
di scommesse e prelievi arrivano all'indirizzo dell'utente e vengono
|
||
accreditati per la stessa strada, quindi producono anche loro un
|
||
`deposit_credited`.
|
||
*Codice:* `app/deposits/service.py:credit_confirmed_utxos` ·
|
||
*Test:* nessun test verifica la riga di audit.
|
||
|
||
- **[AUD-17]** `utxo_spent_externally` — un UTXO che il database considerava
|
||
non speso non è più tra quelli non spesi, secondo il server attivo e la
|
||
maggioranza degli altri: è stato speso fuori dalla piattaforma (per esempio
|
||
con la chiave esportata). Una riga per UTXO. Payload
|
||
`{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no. Stessa
|
||
transazione della marcatura (`spent_txid = "external-spend"`) e del ricalcolo
|
||
del saldo.
|
||
*Codice:* `app/deposits/service.py:mark_utxos_spent_externally` ·
|
||
*Test:* `tests/unit/test_deposits.py`
|
||
|
||
- **[AUD-18]** `utxo_external_spend_reinstated` — un UTXO segnato come speso
|
||
all'esterno è ricomparso tra i non spesi, il quorum degli altri server lo ha
|
||
confermato non speso con lo stesso importo
|
||
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito), DEP-08)
|
||
e la marcatura è stata annullata. Payload `{"txid", "vout", "amount_sats"}`.
|
||
`user_id` sì, `round_id` no. Stessa transazione dell'annullamento e del
|
||
ricalcolo del saldo.
|
||
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_confirmation_proof.py`
|
||
|
||
- **[AUD-19]** `bet_placed` — la rete ha accettato la transazione di una
|
||
scommessa. Payload `{"txid", "amount_sats"}`: `txid` è quello del primo
|
||
invio (un successivo RBF non aggiorna la riga), `amount_sats` è l'importo
|
||
che arriva al montepremi, cioè la puntata **al netto** della commissione di
|
||
rete. `user_id` sì, `round_id` sì. Stessa transazione che porta il
|
||
partecipante a `broadcast` e la transazione pendente a `pending`.
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
*Test:* `tests/unit/test_bets.py`
|
||
|
||
- **[AUD-20]** `bet_broadcast_failed` — la rete ha rifiutato **esplicitamente**
|
||
la transazione di una scommessa; gli UTXO sono stati liberati e il
|
||
partecipante rimosso. Un esito incerto (timeout, connessione caduta dopo
|
||
l'invio) non scrive questo evento: la riga resta `building` per il
|
||
riconciliatore
|
||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede), B-76).
|
||
Payload `{"txid", "reason"}`, con `reason` il testo dell'errore troncato a
|
||
200 caratteri. `user_id` sì, `round_id` **no** (a differenza di
|
||
`bet_placed`). Stessa transazione del ripristino.
|
||
*Codice:* `app/bets/service.py:_release_failed_bet` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_broadcast_outcome.py`
|
||
|
||
- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un
|
||
prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`:
|
||
`amount_sent_sats` è quanto arriva davvero all'indirizzo esterno (al netto
|
||
della commissione e dell'eventuale limite di polvere, vedi
|
||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md)). L'id del prelievo non è
|
||
nel payload. `user_id` sì, `round_id` no. Stessa transazione che porta il
|
||
prelievo a `broadcast`.
|
||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||
*Test:* nessun test verifica la riga di audit.
|
||
|
||
- **[AUD-22]** `withdrawal_broadcast_failed` — la rete ha rifiutato
|
||
**esplicitamente** la transazione di un prelievo; gli UTXO sono stati
|
||
liberati e il prelievo segnato `failed`. Come per [AUD-20], un esito
|
||
incerto non scrive questo evento (B-76). Payload `{"txid", "reason"}`
|
||
(`reason` troncato a 200 caratteri). `user_id` sì, `round_id` no. Stessa
|
||
transazione del ripristino.
|
||
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||
*Test:* `tests/unit/test_broadcast_outcome.py`
|
||
|
||
### Round
|
||
|
||
- **[AUD-23]** `round_closed` — un round è stato chiuso **senza partecipanti
|
||
confermati**. Payload `{"participants": 0}`. `user_id` no, `round_id` sì.
|
||
Stessa transazione del passaggio a `closed`. È l'unico caso in cui la
|
||
chiusura di un round viene registrata: un round chiuso dopo la conferma del
|
||
pagamento non produce alcuna riga (vedi [AUD-32]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-46]** `closing_stalled` — un round è in `closing` oltre la propria
|
||
scadenza da più di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s) perché
|
||
almeno una scommessa è ancora in volo. La regola, soglia e ripetizione
|
||
comprese, è
|
||
[TX-29](transazioni-e-riconciliazione.md#riconciliazione); qui c'è solo la
|
||
voce del catalogo. Payload `{"in_flight", "txids", "seconds_past_deadline"}`:
|
||
numero di scommesse in `building` o `broadcast`, lista dei loro txid
|
||
correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì.
|
||
Transazione propria ([AUD-03]). La riga più recente del round è l'orologio
|
||
della ripetizione successiva, 1800 s dopo ([AUD-09]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* —
|
||
*Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive
|
||
alcun evento.
|
||
|
||
- **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei
|
||
partecipanti e altezza del blocco di estrazione sono fissati
|
||
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura),
|
||
RND-14, RND-33). Payload `{"participants_hash", "draw_height",
|
||
"participants", "server_seed_hash"}`: impronta dell'elenco ordinato,
|
||
altezza del blocco di estrazione, numero di partecipanti confermati,
|
||
impronta del segreto pubblicata all'apertura (RND-34). `server_seed` **non**
|
||
compare. `user_id` no, `round_id` sì. Stessa transazione che porta il round
|
||
a `drawing` e salva `draw_position`, `participants_hash` e `draw_height`.
|
||
Poiché la riga è salvata prima che il blocco a `draw_height` esista, il
|
||
registro prova che impronta e altezza erano fissate in anticipo. Nessuna
|
||
logica rilegge la riga: la ripresa dell'estrazione (RND-36) legge i valori
|
||
dal round.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-24]** `winner_drawn` — il vincitore è stato estratto e salvato; in
|
||
questa riga il segreto del round viene **rivelato**. Payload
|
||
`{"winner_address", "pool_amount_sats", "block_height", "block_hash",
|
||
"participants", "draw_height", "participants_hash", "server_seed",
|
||
"seed"}`: indirizzo del vincitore, montepremi totale (somma delle puntate
|
||
nette), altezza e hash del blocco usato, numero di partecipanti, altezza del
|
||
blocco di estrazione fissata all'ingresso in `drawing` (uguale a
|
||
`block_height`), impronta dei partecipanti (uguale a quella di
|
||
`draw_committed`), `server_seed` in esadecimale (64 caratteri) e il seme
|
||
calcolato, in esadecimale (64 caratteri, lo stesso valore di `seed_int`:
|
||
come stringa, perché un intero di 256 bit perde precisione in JavaScript).
|
||
Con `draw_committed` e la catena, la riga basta a rifare il calcolo di
|
||
[RND-17](ciclo-di-vita-del-round.md#3--estrazione). `user_id` sì (il
|
||
vincitore), `round_id` sì. Stessa transazione che salva il vincitore sul
|
||
round e lo porta a `paying_out` ([RND-18](ciclo-di-vita-del-round.md#3--estrazione)):
|
||
`server_seed` non può quindi comparire nel registro prima che il vincitore
|
||
sia salvato.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._complete_draw` ·
|
||
*Test:* `tests/unit/test_draw_proof.py`
|
||
|
||
- **[AUD-25]** `draw_baseline_tip_unknown` — quando il round sarebbe pronto
|
||
per `drawing`, l'altezza della catena non era nota: il round resta in
|
||
`closing` e il passaggio si ritenta al passo successivo
|
||
([RND-19](ciclo-di-vita-del-round.md#3--estrazione)). Payload vuoto `{}`.
|
||
Una sola riga per round ([AUD-09]). `user_id` no, `round_id` sì.
|
||
Transazione propria ([AUD-03]). Dovrebbe essere irraggiungibile (lo
|
||
scheduler non lavora senza un'altezza nota).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-26]** `draw_header_corroboration_failed` — raggiunte le conferme
|
||
richieste, il quorum dei server non concorda sull'header all'altezza
|
||
`draw_height`, o non risponde abbastanza
|
||
([RND-16](ciclo-di-vita-del-round.md#3--estrazione)); al passo successivo
|
||
si richiede la stessa altezza. Una riga per ogni tentativo fallito. Payload
|
||
`{"height", "reported_hash"}`: `height` è sempre `draw_height`,
|
||
`reported_hash` l'hash riportato dal server attivo. `user_id` no,
|
||
`round_id` sì. Transazione propria ([AUD-03]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-27]** `draw_stalled` — l'estrazione è in stallo secondo la regola di
|
||
[RND-20](ciclo-di-vita-del-round.md#3--estrazione), che ne è il
|
||
proprietario (soglia, condizioni e ripetizione); qui c'è solo la voce del
|
||
catalogo. Payload `{"draw_height", "current_tip_height",
|
||
"elapsed_seconds"}`: altezza del blocco di estrazione, altezza della catena
|
||
nota in quel momento, secondi da quando dura la condizione di RND-20.
|
||
`user_id` no, `round_id` sì. Transazione propria ([AUD-03]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito.
|
||
Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne
|
||
quando il motivo è l'assenza di connessione (vuoto); `round_id` sì.
|
||
Transazione propria e *best effort* ([AUD-03]); è l'orologio del nuovo
|
||
tentativo ([AUD-09]). Valori possibili di `reason`:
|
||
|
||
| `reason` | Causa |
|
||
|---|---|
|
||
| `electrum client not connected` | Nessuna connessione Electrum al momento del tentativo |
|
||
| `no fee_address configured` | `fee_address` vuoto nella configurazione |
|
||
| `winner user not found` | L'utente vincitore non esiste più nel database |
|
||
| `insufficient pool UTXOs` | Gli UTXO confermati e non riservati dell'indirizzo del montepremi non coprono l'importo |
|
||
| `too_many_inputs` | Servirebbero più di `MAX_PAYOUT_TX_INPUTS` (500) input |
|
||
| `winner_share_below_network_fee` | La quota del vincitore non copre la commissione di rete |
|
||
| `commission_below_dust_limit` | La quota della piattaforma è sotto il limite di polvere |
|
||
| `payout build failed` | Qualunque altro errore nella costruzione (per esempio un `fee_address` malformato) |
|
||
| `broadcast rejected` | La rete ha rifiutato la transazione; la riga `building` resta al riconciliatore |
|
||
|
||
Un tentativo saltato perché esiste già un pagamento `building` o `pending`
|
||
per il round **non** scrive `payout_failed`, e nemmeno un pagamento
|
||
trattenuto perché la scommessa del vincitore non è più sulla catena, che
|
||
scrive invece `payout_blocked` ([AUD-44]). L'elenco dei motivi è di
|
||
[RND-25](ciclo-di-vita-del-round.md#4--pagamento); la verifica senza
|
||
quorum di [RND-39](ciclo-di-vita-del-round.md#4--pagamento) termina anche
|
||
lei con `payout_failed`, motivo `pool verification unavailable`. Cosa fare per ogni motivo è descritto in
|
||
[operativita.md](operativita.md).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-29]** `payout_sent` — la rete ha accettato la transazione di
|
||
pagamento. Payload `{"txid", "winner_sats", "commission_sats"}`: quanto
|
||
riceve il vincitore (già al netto della commissione di rete) e quanto va a
|
||
`fee_address`. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
|
||
che porta la transazione pendente da `building` a `pending`. Per un
|
||
pagamento promosso dal riconciliatore vale [AUD-30].
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[AUD-45]** `participant_reorged_out` — la verifica prima del pagamento
|
||
ha trovato assente, secondo il quorum, l'uscita della scommessa di un
|
||
partecipante, che passa a `reorged_out` ed è escluso dal montepremi
|
||
([RND-39](ciclo-di-vita-del-round.md#4--pagamento), DAT-67). Una riga per
|
||
partecipante. Payload `{"txid", "vout", "amount_sats", "pool_amount_sats"}`:
|
||
l'uscita della scommessa sparita, il suo importo netto e il montepremi
|
||
riscritto dopo l'esclusione. `user_id` sì (il partecipante), `round_id` sì.
|
||
Stessa transazione del passaggio a `reorged_out` e della riscrittura di
|
||
`pool_amount_sats`. Nessuna logica rilegge la riga.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||
*Test:* `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`, `tests/unit/test_pool_verification.py::test_several_bets_gone_at_once_each_get_a_row_in_one_transaction`
|
||
|
||
- **[AUD-44]** `payout_blocked` — la verifica prima del pagamento ha trovato
|
||
assente la scommessa **del vincitore**: il pagamento non si costruisce, il
|
||
round resta in `paying_out` con `payout_blocked_at` e la lotteria va in
|
||
pausa automatica
|
||
([RND-39](ciclo-di-vita-del-round.md#4--pagamento), DAT-68). Payload
|
||
`{"txid", "vout", "amount_sats"}`: l'uscita della scommessa del vincitore.
|
||
`user_id` sì (il vincitore), `round_id` sì. Stessa transazione di
|
||
`payout_blocked_at`, della pausa e della riga `lottery_paused` con
|
||
`reason = "payout_blocked"` ([AUD-11]). Dopo `POST /admin/resume` la
|
||
verifica si ripete: se la scommessa è ancora assente si scrive una nuova
|
||
riga. Non è un `payout_failed` e non fa da orologio dei tentativi.
|
||
Procedura in [operativita.md](operativita.md) (OPS-30).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`
|
||
|
||
### Riconciliazione
|
||
|
||
- **[AUD-30]** `pending_tx_recovered` — una transazione rimasta `building`
|
||
(tipicamente per un riavvio a metà invio) risulta invece presente in catena
|
||
ed è stata promossa a `pending`. Payload
|
||
`{"pending_transaction_id", "kind", "txid"}`. `user_id` e `round_id`
|
||
dipendono dal tipo: sono quelli della riga `pending_transactions` (per un
|
||
pagamento `user_id` è vuoto, per un prelievo `round_id` è vuoto). Nella
|
||
stessa transazione della promozione si scrive anche l'evento che l'invio
|
||
riuscito avrebbe scritto — `bet_placed` ([AUD-19]), `withdrawal_sent`
|
||
([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload
|
||
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
|
||
*Codice:* `app/tx/reconcile.py:_promote` ·
|
||
*Test:* nessun test verifica la riga di audit.
|
||
*Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive
|
||
solo `pending_tx_recovered`.
|
||
|
||
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
|
||
è stata dichiarata persa **con una prova**
|
||
([TX-17](transazioni-e-riconciliazione.md#riconciliazione)): stato
|
||
`failed`, UTXO liberati, partecipante rimosso o prelievo `failed`,
|
||
`payout_txid` del round azzerato. Payload
|
||
`{"pending_transaction_id", "kind", "txid", "reason", "utxos_released"}`, con
|
||
`reason` uguale al `failure_reason` salvato sulla riga, che nomina la prova
|
||
([modello-dati.md](modello-dati.md), DAT-36), e `utxos_released` il numero
|
||
di UTXO liberati (sempre 0 per un pagamento, perché l'indirizzo del
|
||
montepremi non è tracciato in `utxo_events`). `user_id` e `round_id` come
|
||
in [AUD-30]. Stessa transazione dell'abbandono.
|
||
*Codice:* `app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py`
|
||
|
||
- **[AUD-39]** `pending_tx_resurrected` — una riga `failed` da meno di 14
|
||
giorni è risultata confermata: una transazione data per persa è arrivata in
|
||
un blocco
|
||
([TX-23](transazioni-e-riconciliazione.md#riconciliazione)). Payload
|
||
`{"pending_transaction_id", "kind", "txid", "block_height"}`: `txid` è la
|
||
versione confermata (una qualunque della catena di sostituzioni),
|
||
`block_height` l'altezza del blocco che la contiene. `user_id` e
|
||
`round_id` come in [AUD-30]. Una sola riga per transazione, perché la riga
|
||
passa a `confirmed` e non viene più sorvegliata. Stessa transazione del
|
||
passaggio a `confirmed`, della pausa e della riga `lottery_paused` con
|
||
`reason = "resurrected_tx"` ([AUD-11]). Le righe collegate non vengono
|
||
corrette: la procedura è in [operativita.md](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](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
|
||
Payload `{"pending_transaction_id", "kind", "txid", "reason", "message"}`:
|
||
`txid` è il txid corrente della riga; `reason` è uno di
|
||
`change_too_small` (il resto non assorbe l'aumento, TX-11),
|
||
`max_fee_rate` (la riga è già a `MAX_FEE_RATE_SAT_VB`) o
|
||
`replacement_rejected` (la rete ha rifiutato esplicitamente la
|
||
sostituzione); `message` è il testo dell'errore, troncato a 200 caratteri.
|
||
`user_id` e `round_id` come in [AUD-30]. Transazione propria ([AUD-03]);
|
||
la riga `pending` non cambia. Si scrive **una volta per coppia (txid
|
||
corrente, `reason`)**, come fissato da TX-24: prima di scrivere si cerca
|
||
nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa
|
||
coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un
|
||
motivo diverso scrivono una riga nuova.
|
||
*Codice:* `app/tx/broadcast.py:RbfBumper._record_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason`, `tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported`, `tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone`, `tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row`
|
||
|
||
### Catena
|
||
|
||
- **[AUD-38]** `chain_reorg_detected` — il listener ha seguito una
|
||
riorganizzazione della catena
|
||
([ELC-24](connessione-electrum.md#riorganizzazioni)). Payload
|
||
`{"depth", "fork_height", "old_tip_height", "old_tip_hash",
|
||
"new_tip_height", "new_tip_hash"}`: numero dei nostri blocchi sostituiti
|
||
(`old_tip_height − fork_height`), altezza del punto di biforcazione,
|
||
altezza e hash della punta prima e dopo. `user_id` no, `round_id` no. Con
|
||
`depth` 1 è solo osservativo (transazione propria, [AUD-03]); con `depth`
|
||
≥ 2 è salvato nella stessa transazione della pausa e della riga
|
||
`lottery_paused` con `reason = "reorg"` ([AUD-11]). Una biforcazione più
|
||
profonda di 10 header non viene seguita e non scrive questo evento (la
|
||
sessione si chiude). Nessuna logica rilegge la riga.
|
||
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
|
||
## Cosa non viene registrato
|
||
|
||
- **[AUD-32]** Questi fatti **non** producono una riga di audit; restano
|
||
ricostruibili solo dallo stato delle tabelle o dal log applicativo:
|
||
- apertura di un round;
|
||
- passaggio di stato del round a `closing`, e la chiusura dopo la
|
||
conferma del pagamento (`_on_payout_confirmed`);
|
||
- conferma di una scommessa, di un prelievo o di un pagamento
|
||
(`ConfirmationPoller`);
|
||
- ogni aumento di commissione RBF **riuscito** (solo il log
|
||
`bumped <tipo> pending_transaction N`);
|
||
- login, login falliti e blocchi per troppi tentativi, compresi i tentativi
|
||
falliti di cambio password;
|
||
- invio di una segnalazione di bug;
|
||
- accesso in lettura al pannello admin (elenchi utenti, round, audit).
|
||
|
||
Escono da questo elenco, perché diventano eventi del catalogo: l'ingresso
|
||
in `drawing` (`draw_committed`, [AUD-37]), ogni RBF impossibile o rifiutato
|
||
(`rbf_bump_failed`, [AUD-40]), la registrazione (`user_registered`,
|
||
[AUD-41]) e il cambio password riuscito (`password_changed`, [AUD-42]).
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`,
|
||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||
`app/rounds/confirmation.py:_on_payout_confirmed`,
|
||
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
|
||
*Stato:* da implementare (B-92, B-94), per i due fatti che escono
|
||
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
|
||
producono alcuna riga.
|
||
|
||
## Log applicativo
|
||
|
||
Il registro di audit è nel database e si vede da `/admin`; il log applicativo
|
||
è un file di testo e contiene molto di più (errori, riconnessioni, attese), ma
|
||
va letto sul server.
|
||
|
||
- **[AUD-33]** All'importazione di `app.main`, `setup_logging` aggiunge al
|
||
logger radice (livello `INFO`) un `RotatingFileHandler` su `logs/app.log`,
|
||
relativo alla radice del progetto: 10 MB per file, 5 file di rotazione
|
||
(`app.log.1` … `app.log.5`), formato
|
||
`data ora LIVELLO [logger] messaggio`. Lo stesso handler è aggiunto a
|
||
`uvicorn.error` e `uvicorn.access`, che non propagano al radice. Nel
|
||
container la radice del progetto è `/app`, e `/app/logs` è montato su
|
||
`./data/logs` dell'host.
|
||
*Codice:* `app/logging_config.py:setup_logging`, `docker-compose.yml`
|
||
|
||
- **[AUD-34]** I messaggi dei moduli dell'applicazione (scheduler, listener,
|
||
riconciliatori) finiscono in `data/logs/app.log` **e** sull'output del
|
||
container, quindi `docker compose logs app` li mostra insieme all'output di
|
||
avvio del container (controllo della chiave, migrazioni Alembic). La regola
|
||
è di [ARC-09](architettura.md#log), che ne è il proprietario.
|
||
*Codice:* `app/logging_config.py:setup_logging`
|
||
*Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo
|
||
l'handler su file: `docker compose logs app` mostra solo l'output di avvio
|
||
del container e ciò che uvicorn scrive con la propria configurazione
|
||
(avvio, richieste).
|
||
|
||
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
|
||
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
|
||
arriva solo un errore generico `internal_error` con stato 500, senza il testo
|
||
dell'eccezione.
|
||
*Codice:* `app/main.py:log_unhandled_exception`
|
||
|
||
- **[AUD-36]** Le richieste di polling delle due interfacce (per esempio
|
||
`/rounds/current` ogni 15 secondi per ogni pagina aperta, più un aggiornamento
|
||
a ogni evento SSE) vengono tutte registrate da `uvicorn.access`. Con 60 MB di spazio complessivo, la rotazione può cancellare
|
||
in poche ore o giorni i messaggi più vecchi: il log non è un archivio, il
|
||
registro di audit sì.
|
||
*Codice:* `app/logging_config.py:setup_logging`
|
||
|
||
### Messaggi di log che contano
|
||
|
||
Messaggi non accompagnati da una riga di audit ma utili in esercizio. Il
|
||
nome tra parentesi quadre nel log è il modulo (`app.rounds.scheduler`, ecc.).
|
||
|
||
| Livello | Modulo | Messaggio (inizio) | Significato |
|
||
|---|---|---|---|
|
||
| WARNING | `app.electrum.listener` | `all N Electrum server(s) unreachable, retrying in Ns` | Tutti i server provati hanno fallito; attesa da 1 a 30s prima del giro successivo |
|
||
| ERROR (con traccia) | `app.electrum.listener` | `Electrum session on … failed` | Errore nell'aprire la sessione: connessione, primo header, primo header rifiutato |
|
||
| INFO | `app.electrum.listener` | `Electrum connected to …` / `Electrum connection to … ended, reconnecting` | Connessione aperta / chiusa normalmente |
|
||
| WARNING | `app.electrum.listener` | `Electrum session on … ending: …` | Motivo della fine di una sessione già aperta (ping scaduto, socket chiuso, header successivo rifiutato con `HeaderValidationError`) |
|
||
| WARNING | `app.electrum.listener` | `ignoring Electrum header at height …` / `ignoring a competing header …` | Header scartato senza chiudere la sessione (senza hex, più basso, concorrente) |
|
||
| WARNING | `app.electrum.listener` | `could not corroborate … with any of N other configured server(s)` | Nessun altro server ha risposto: la corroborazione fallisce |
|
||
| WARNING | `app.electrum.listener` | `N new UTXO(s) for user_id=… not corroborated by the other servers` | Deposito non ancora accreditato; si riprova al prossimo aggiornamento |
|
||
| WARNING | `app.electrum.listener` | `N UTXO(s) spent outside the platform for user_id=…` | Accompagna `utxo_spent_externally` |
|
||
| WARNING | `app.electrum.listener` | `could not subscribe a newly-registered user's address` / `resubscribing all users failed partway through` | Sottoscrizione persa; la recupera `DepositReconciler` entro 300s |
|
||
| ERROR (con traccia) | `app.electrum.listener` | `failed to resubscribe user_id=…` | Un singolo utente non risottoscritto alla riconnessione |
|
||
| WARNING | `app.deposits.service` | `listunspent for user_id=… returned no entries at all …` | Risposta vuota per un indirizzo con fondi: ignorata come incompleta |
|
||
| ERROR (con traccia) | `app.deposits.reconcile` | `deposit reconciliation failed for user_id=…` | Il controllo periodico di un utente è fallito |
|
||
| ERROR (con traccia) | `app.rounds.scheduler` | `round scheduler tick failed` | Eccezione in un ciclo dello scheduler. Se avviene durante `drawing`, il round resta bloccato (vedi [operativita.md](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]) |
|