Complete the target spec for the 2026-09-28 review (B-73..B-98)
Finishes what 88221b0 started: sicurezza.md, audit-log.md and storico-bug.md
now describe the target behaviour too, the operator guides are corrected
against today's code, and api.md gains the missing /health/ready and
/admin/electrum sections plus every new error code in its catalog.
Harmonised names and owners across documents (the `resurrected_tx` pause
reason, the reconciler as the one that watches failed rows, the full
`winner_drawn` payload, a reason for an unanswered pre-payout pool check,
one corroboration-failure row per hash instead of one per tick).
Adds B-98 (coin selection stops at the first covering UTXO and refuses a
payable bet), tightens RND-40 to check both 1 and 400 participants and to
guard PUT /admin/config, and gives a stalled `closing` round a signal
(TX-29, closing_stalled).
Records three decisions confirmed on 2026-09-29: the draw's server_seed is
stored Fernet-encrypted until revealed (SEC-56, DAT-64), the chosen abuse
and key-check thresholds (SEC-49, SEC-54, SEC-55), and a broadcast
rejection is verified against every server before any coin is released,
so matching "already known" text is a shortcut, not a safety condition
(TX-04).
Spec only; no code changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88221b0dbd
commit
4be58709d1
16 files changed
+1377
-323
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# BUGS.md — open findings
|
||||
|
||||
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
|
||||
2026-09-28 specification review (B-73 … B-97, at the end of the file).
|
||||
2026-09-28 specification review (B-73 … B-98, at the end of the file).
|
||||
|
||||
## Audit of 2026-08-03
|
||||
|
||||
@@ -73,7 +73,7 @@ way to run the server.
|
||||
|
||||
---
|
||||
|
||||
# Audit of 2026-09-28 — specification review (B-73 … B-97)
|
||||
# Audit of 2026-09-28 — specification review (B-73 … B-98)
|
||||
|
||||
Fourth audit, run against the new specification in `docs/` rather than the code
|
||||
alone: four independent reviews (coin/chain mechanics, money state machine,
|
||||
@@ -110,7 +110,9 @@ is used (it only has to skip announcing one; corroboration checks that the block
|
||||
exists, not that it is the first after closing). The participant order uses the
|
||||
server-only `broadcast_at` with no tie-break, so the draw is not reproducible.
|
||||
|
||||
Fix (commit-reveal): at round open generate a 32-byte `server_seed`, store it,
|
||||
Fix (commit-reveal): at round open generate a 32-byte `server_seed`, store it
|
||||
**encrypted** (`server_seed_enc`, Fernet with the master-xprv key, so a DB copy or
|
||||
backup alone never reveals it; the plaintext column is written only at reveal), and
|
||||
publish only `server_seed_hash = sha256(server_seed)`. On entering `drawing`, fix
|
||||
the participant list ordered by confirmed bet txid (ascending), publish
|
||||
`participants_hash = sha256` of the `txid:vout:address` lines, and fix
|
||||
@@ -155,8 +157,13 @@ including the 15s timeout and a drop after the write. The tx may have propagated
|
||||
bet's sats land in the pool with no participant, the withdrawal leaves while its row
|
||||
says `failed`.
|
||||
|
||||
Fix: only an explicit JSON-RPC rejection releases immediately (an "already known /
|
||||
already in mempool" rejection is success). A timeout or disconnect leaves the rows
|
||||
Fix: only an explicit JSON-RPC rejection can release, and only after verifying it:
|
||||
`blockchain.transaction.get(txid)` is asked of the active server and every fallback
|
||||
(10s each). Any server returning the tx → accepted; every responder denying it, with
|
||||
the active server and the quorum minimum among them → released; too few answers →
|
||||
uncertain (below). Matching "already known / already in mempool" messages is only a
|
||||
shortcut that skips the check, so an unrecognised message can never release a tx
|
||||
that is on the network (spec: TX-04). A timeout or disconnect leaves the rows
|
||||
`building` for the reconciler and the API answers 202 with the row in `building`.
|
||||
After the active server accepts, the raw tx is also sent to every fallback
|
||||
(best effort).
|
||||
@@ -201,7 +208,9 @@ Fix: every payout carries a **bump reserve** output of
|
||||
`max(DUST_LIMIT_SATS, estimated_vsize × 10 sat/vB)`, taken from the commission share
|
||||
and paid to the pool reserve address (`m/84'/746'/0'/1/0`, the old pool address);
|
||||
RBF shrinks that output. `rounds_can_open` refuses a configuration in which a
|
||||
one-participant round could not cover winner fee + reserve + dust commission.
|
||||
round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winner
|
||||
fee + reserve + dust commission, and `PUT /admin/config` refuses a bet amount or
|
||||
fee rate that would fail the same check (spec: RND-40).
|
||||
|
||||
### B-80 — One pool address for every round
|
||||
|
||||
@@ -385,9 +394,12 @@ payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writ
|
||||
row it checks; concurrent refreshes of one user can collide on the unique outpoint
|
||||
and end the Electrum session; `listener.client` is read outside the per-user lock;
|
||||
a bet covered only by pending change gets `insufficient_balance` instead of
|
||||
`balance_pending_confirmation`.
|
||||
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
|
||||
leaves the mempool gives no signal at all.
|
||||
|
||||
Fix: each as described, one regression test per item.
|
||||
Fix: each as described, one regression test per item; for the `closing` wait, a
|
||||
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
|
||||
on `GET /rounds/current` (spec: TX-29).
|
||||
|
||||
### B-97 — Migrations can silently drop the expression indexes
|
||||
|
||||
@@ -397,3 +409,16 @@ runs the migrations.
|
||||
|
||||
Fix: a test that runs `alembic upgrade head` on an empty DB and asserts both indexes
|
||||
exist; any batch migration on those tables recreates them explicitly.
|
||||
|
||||
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
|
||||
|
||||
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
|
||||
as the selected total covers the target. When that happens exactly (a 10 PLM coin
|
||||
for a 10 PLM bet), the change is 0: the bet is refused with
|
||||
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
|
||||
though a second coin would have left a proper change output.
|
||||
|
||||
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
|
||||
still capped at `max_inputs`); only when every allowed coin together cannot reach it
|
||||
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
|
||||
`docs/scommesse-e-prelievi.md` BET-24.
|
||||
@@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested —
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-97: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-97 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
|
||||
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-98 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
|
||||
|
||||
**[docs/](docs/) is the specification, and it is authoritative.** Every verifiable rule there has a stable requirement ID (`RND-04`, `BET-02`, …) with `*Codice:*`/`*Test:*` pointers to what implements it; the index is [docs/come-funziona.md](docs/come-funziona.md) and the ID convention and change workflow are in [docs/processo-di-modifica.md](docs/processo-di-modifica.md). A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below.
|
||||
|
||||
|
||||
+51
-2
@@ -940,6 +940,21 @@ dei parametri operativi in
|
||||
*Codice:* `app/api/routes/admin.py:list_pending_transactions` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
|
||||
#### GET /admin/electrum
|
||||
|
||||
- **[API-47]** Stato della connessione alla rete, per l'indicatore Electrum di
|
||||
`/admin`. Risponde `200` con: `connected` (booleano), `endpoint` (server
|
||||
attivo come `host:port`, `null` se non connesso), `tip_height`,
|
||||
`last_header_age_seconds`, `fallback_count` (server di riserva configurati),
|
||||
`quorum_enforced` (falso solo con `ALLOW_NO_QUORUM=true`,
|
||||
[connessione-electrum.md](connessione-electrum.md) ELC-19) e
|
||||
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
|
||||
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
|
||||
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-86, B-92) · *Oggi:* l'endpoint non esiste;
|
||||
`/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
|
||||
|
||||
#### GET /admin/bug-reports
|
||||
|
||||
- **[API-39]** Tutte le segnalazioni, dalla più recente.
|
||||
@@ -972,8 +987,31 @@ dei parametri operativi in
|
||||
- **[API-41]** Risponde sempre `200 {"status": "ok"}` finché il processo
|
||||
serve richieste. Non verifica né il database né la connessione Electrum:
|
||||
per lo stato della rete si usa `chain_tip_height` di `GET /rounds/current`.
|
||||
È il controllo di *vitalità* (il processo risponde); quello di *prontezza*
|
||||
è [API-46].
|
||||
*Codice:* `app/main.py:health`
|
||||
|
||||
#### GET /health/ready
|
||||
|
||||
- **[API-46]** Risponde `200 {"status": "ready", "checks": {...}}` solo se
|
||||
**tutte** queste condizioni sono vere, altrimenti `503` con lo stesso corpo e
|
||||
`"status": "not_ready"`:
|
||||
- la connessione Electrum è attiva (`listener.client` non nullo);
|
||||
- l'ultimo header è stato accettato da meno di 360 secondi
|
||||
([connessione-electrum.md](connessione-electrum.md) ELC-29);
|
||||
- lo scheduler dei round ha battuto da meno di 30 secondi (il battito si
|
||||
aggiorna a ogni giro e a ogni iterazione dell'attesa del blocco di
|
||||
estrazione, [architettura.md](architettura.md) ARC-61).
|
||||
|
||||
`checks` riporta ogni condizione con il suo valore: `electrum_connected`
|
||||
(booleano), `last_header_age_seconds` (intero o `null`),
|
||||
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
|
||||
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
|
||||
`healthcheck` del container.
|
||||
*Codice:* `app/main.py:health` · *Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
|
||||
`GET /health` ([API-41]).
|
||||
|
||||
#### Pagine e file statici
|
||||
|
||||
- **[API-42]** `GET /`, `/admin`, `/guida` e `/report-bug` restituiscono
|
||||
@@ -1003,7 +1041,7 @@ dei parametri operativi in
|
||||
|---|---|---|---|---|---|
|
||||
| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì |
|
||||
| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login` | `retry_after_seconds` | quota di registrazione o blocco per tentativi di login falliti | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
|
||||
| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì |
|
||||
| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì |
|
||||
| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì |
|
||||
@@ -1014,7 +1052,7 @@ dei parametri operativi in
|
||||
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
|
||||
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
|
||||
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
|
||||
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
|
||||
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
|
||||
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
|
||||
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
|
||||
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
|
||||
@@ -1024,6 +1062,17 @@ dei parametri operativi in
|
||||
| `withdrawal_to_own_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale all'indirizzo di deposito dell'utente | sì |
|
||||
| `amount_below_minimum` | 400 | `app/withdrawals/service.py:request_withdrawal` | `minimum_sats` | prelievo sotto il costo attuale della scommessa | sì |
|
||||
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
|
||||
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni — *da implementare (B-80)* | no — da aggiungere |
|
||||
| `service_busy` | 503 | `app/bets/service.py:place_bet` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare — *da implementare (B-88)* | no — da aggiungere |
|
||||
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
|
||||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente — *da implementare (B-73)* | no — da aggiungere |
|
||||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
|
||||
Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee`
|
||||
e `commission_below_dust_limit` — riguardano solo la costruzione del
|
||||
|
||||
@@ -53,7 +53,7 @@ flowchart LR
|
||||
`exec`. Le migrazioni non si lanciano mai a mano. Il processo gira come
|
||||
utente non root, su un'immagine di base fissata per digest e con dipendenze
|
||||
installate da un file di lock con gli hash
|
||||
([sicurezza.md](sicurezza.md) (B-91)). I controlli che richiedono il codice
|
||||
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
|
||||
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
|
||||
@@ -603,6 +603,7 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
| `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario |
|
||||
| `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario |
|
||||
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
|
||||
| `electrum_cert_pin` | `ELECTRUM_CERT_PIN` | `""` | SHA-256 del certificato DER del primario; vuoto = non fissato ([connessione-electrum.md](connessione-electrum.md) ELC-03) |
|
||||
| `allow_no_quorum` | `ALLOW_NO_QUORUM` | `False` | Permette di partire con meno di 2 server di riserva; solo sviluppo ([ARC-60]) |
|
||||
| `xprv_encryption_key_file` | `XPRV_ENCRYPTION_KEY_FILE` | `/run/secrets/xprv_encryption_key` | File con la chiave Fernet della master xprv (obbligatorio, [ARC-19]); sostituisce `XPRV_ENCRYPTION_KEY` |
|
||||
| `start_paused` | `START_PAUSED` | `False` | Avvia con la lotteria in pausa, dopo un ripristino ([ARC-60]) |
|
||||
|
||||
+406
-96
@@ -28,21 +28,34 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
*Codice:* i chiamanti elencati nel catalogo, ognuno dei quali chiama
|
||||
`write_audit_log` e poi `session.commit()` sulla stessa sessione.
|
||||
|
||||
- **[AUD-03]** Quattro eventi sono **solo osservativi**: non accompagnano un
|
||||
cambiamento di stato e vengono salvati in una transazione propria, aperta
|
||||
apposta. Sono `draw_baseline_tip_unknown`, `draw_header_corroboration_failed`,
|
||||
`draw_stalled` e `payout_failed`. Per `payout_failed` la scrittura è anche
|
||||
*best effort*: se fallisce, l'errore viene registrato nel log applicativo
|
||||
- **[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_next_block`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure`
|
||||
`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_header`
|
||||
*Stato:* 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 colonna
|
||||
`payload_json`; `created_at` è impostato dal database all'inserimento, in
|
||||
UTC. `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:AuditLog`
|
||||
`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
|
||||
@@ -52,20 +65,27 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
- **[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.
|
||||
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), con il parametro
|
||||
`limit` (predefinito 200, minimo 1, massimo 500). Ogni elemento contiene
|
||||
`id`, `event_type`, `payload` (già decodificato da JSON), `user_id`,
|
||||
`round_id` e `created_at` in ISO 8601 UTC. **Non esistono filtri** per tipo
|
||||
di evento, utente o round, né paginazione: le righe più vecchie delle ultime
|
||||
500 si leggono solo direttamente dal database.
|
||||
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
|
||||
@@ -73,20 +93,36 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
filtri né ricerca.
|
||||
*Codice:* `app/static/admin.js:loadAuditLog`
|
||||
|
||||
- **[AUD-09]** Una sola logica del programma rilegge il registro:
|
||||
`_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. Nessun'altra decisione dipende dal
|
||||
registro.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[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-87, B-96, B-73), per le ultime tre letture ·
|
||||
*Oggi:* l'unica lettura è quella di `payout_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.
|
||||
sempre in satoshi. Nel diagramma, gli eventi marcati con * sono da
|
||||
implementare.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -98,6 +134,11 @@ flowchart LR
|
||||
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
|
||||
@@ -109,16 +150,25 @@ flowchart LR
|
||||
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
|
||||
```
|
||||
|
||||
@@ -129,23 +179,54 @@ flowchart LR
|
||||
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`). Un campo inviato con lo
|
||||
`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`
|
||||
*Stato:* da implementare (B-73, B-82, B-95), per gli ultimi tre campi ·
|
||||
*Oggi:* i campi sono i primi sette.
|
||||
|
||||
- **[AUD-11]** `lottery_paused` — `POST /admin/pause`. Payload vuoto `{}`.
|
||||
`user_id` no, `round_id` no. Viene scritto a ogni chiamata, anche se la
|
||||
lotteria era già in pausa. Stessa transazione di `paused = true`.
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||||
- **[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._apply_header`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||||
`app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* nessun test verifica la riga di audit.
|
||||
*Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* scritto solo
|
||||
da `POST /admin/pause`, con payload vuoto `{}`.
|
||||
|
||||
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload vuoto `{}`.
|
||||
`user_id` no, `round_id` no. Scritto a ogni chiamata, anche se la lotteria
|
||||
non era in pausa. Stessa transazione di `paused = false`.
|
||||
- **[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:* nessun test verifica la riga di audit.
|
||||
*Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* payload vuoto `{}`.
|
||||
|
||||
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
|
||||
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
|
||||
@@ -175,6 +256,46 @@ flowchart LR
|
||||
*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
|
||||
@@ -201,11 +322,16 @@ flowchart LR
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
|
||||
- **[AUD-18]** `utxo_external_spend_reinstated` — un UTXO segnato come speso
|
||||
all'esterno è ricomparso tra i non spesi 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.
|
||||
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`
|
||||
*Stato:* da implementare (B-83), per la conferma del quorum · *Oggi:* basta
|
||||
la parola del server attivo.
|
||||
|
||||
- **[AUD-19]** `bet_placed` — la rete ha accettato la transazione di una
|
||||
scommessa. Payload `{"txid", "amount_sats"}`: `txid` è quello del primo
|
||||
@@ -216,13 +342,19 @@ flowchart LR
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
|
||||
- **[AUD-20]** `bet_broadcast_failed` — la rete ha rifiutato la transazione di
|
||||
una scommessa; gli UTXO sono stati liberati e il partecipante rimosso.
|
||||
- **[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`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore
|
||||
d'invio, compresi timeout e disconnessioni.
|
||||
|
||||
- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un
|
||||
prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`:
|
||||
@@ -234,12 +366,16 @@ flowchart LR
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* nessun test verifica la riga di audit.
|
||||
|
||||
- **[AUD-22]** `withdrawal_broadcast_failed` — la rete ha rifiutato la
|
||||
transazione di un prelievo; gli UTXO sono stati liberati e il prelievo
|
||||
segnato `failed`. Payload `{"txid", "reason"}` (`reason` troncato a 200
|
||||
caratteri). `user_id` sì, `round_id` no. Stessa transazione del ripristino.
|
||||
- **[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:* nessun test verifica la riga di audit.
|
||||
*Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore
|
||||
d'invio, compresi timeout e disconnessioni.
|
||||
|
||||
### Round
|
||||
|
||||
@@ -251,44 +387,105 @@ flowchart LR
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
|
||||
- **[AUD-24]** `winner_drawn` — il vincitore è stato estratto. Payload
|
||||
- **[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:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* l'ingresso in `drawing` non
|
||||
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"}`: indirizzo del vincitore, montepremi totale (somma delle
|
||||
puntate nette), altezza e hash del blocco usato come seme, numero di
|
||||
partecipanti. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione
|
||||
che salva i dati dell'estrazione sul round e lo porta a `paying_out`.
|
||||
"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._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* 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` — al momento della chiusura
|
||||
l'altezza della catena non era nota, quindi il primo blocco ricevuto è stato
|
||||
preso come base e l'estrazione aspetta il blocco successivo. Payload
|
||||
`{"baseline_height"}`. `user_id` no, `round_id` sì. Transazione propria
|
||||
([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza
|
||||
un'altezza nota), vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
|
||||
*Test:* `tests/unit/test_scheduler.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`,
|
||||
`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 in
|
||||
`drawing`, 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` — il blocco candidato per
|
||||
l'estrazione non è stato confermato dalla maggioranza degli altri server
|
||||
Electrum (o nessuno ha risposto); l'estrazione aspetta un blocco ulteriore.
|
||||
Una riga per ogni blocco candidato rifiutato. Payload
|
||||
`{"height", "reported_hash"}`. `user_id` no, `round_id` sì. Transazione
|
||||
propria ([AUD-03]).
|
||||
- **[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_next_block` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
|
||||
*Stato:* da implementare (B-73) · *Oggi:* una riga per ogni blocco
|
||||
candidato rifiutato, con `height` l'altezza di quel candidato; l'estrazione
|
||||
aspetta poi un blocco ulteriore invece di richiedere la stessa altezza.
|
||||
|
||||
- **[AUD-27]** `draw_stalled` — l'estrazione aspetta un nuovo blocco da più di
|
||||
360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`), contati da
|
||||
`drawing_started_at`; la riga si ripete ogni 360 secondi finché l'attesa
|
||||
continua. Payload `{"tip_at_close", "current_tip_height",
|
||||
"elapsed_seconds"}`. Nota: `tip_at_close` è la base *attuale* dell'attesa, che
|
||||
dopo un `draw_header_corroboration_failed` diventa l'altezza del candidato
|
||||
rifiutato, non più quella della chiusura. `user_id` no, `round_id` sì.
|
||||
Transazione propria ([AUD-03]). Non viene scritta mentre è in corso una
|
||||
corroborazione, solo mentre si aspetta un blocco.
|
||||
- **[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_next_block` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*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 da `drawing_started_at`, e mai
|
||||
durante una corroborazione; payload `{"tip_at_close",
|
||||
"current_tip_height", "elapsed_seconds"}`, dove `tip_at_close` è la base
|
||||
*attuale* dell'attesa, che dopo un `draw_header_corroboration_failed`
|
||||
diventa l'altezza del candidato rifiutato.
|
||||
|
||||
- **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito.
|
||||
Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne
|
||||
@@ -309,8 +506,13 @@ flowchart LR
|
||||
| `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`. Cosa fare per ogni motivo è
|
||||
descritto in [operativita.md](operativita.md).
|
||||
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` (B-82). 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`
|
||||
@@ -319,10 +521,42 @@ flowchart LR
|
||||
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`.
|
||||
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` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* nessun partecipante viene mai
|
||||
escluso; il pagamento fallisce con `insufficient 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 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` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento fallisce con
|
||||
`insufficient pool UTXOs` a ogni tentativo, senza pausa.
|
||||
|
||||
### Riconciliazione
|
||||
|
||||
- **[AUD-30]** `pending_tx_recovered` — una transazione rimasta `building`
|
||||
@@ -330,42 +564,115 @@ flowchart LR
|
||||
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). Stessa
|
||||
transazione della promozione. Vedi
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||||
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`
|
||||
non compare nella cronologia del proprio indirizzo ed è stata dichiarata
|
||||
persa: stato `failed`, UTXO liberati, partecipante rimosso o prelievo
|
||||
`failed`, `payout_txid` del round azzerato. Payload
|
||||
è 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` oggi sempre `"not found on chain"` 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.
|
||||
`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`
|
||||
*Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del
|
||||
server attivo non contenga il txid corrente, e `reason` è sempre
|
||||
`"not found on chain"`.
|
||||
|
||||
- **[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:reconcile_once` · *Test:* —
|
||||
*Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più
|
||||
guardata e l'evento non esiste.
|
||||
|
||||
- **[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._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](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._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;
|
||||
- passaggi di stato del round a `closing` e `drawing`, e la chiusura dopo la
|
||||
- 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 e ogni RBF impossibile (solo log);
|
||||
- registrazione, login, login falliti e blocchi per troppi tentativi;
|
||||
- cambio password fatto dall'utente (`POST /users/me/change-password`);
|
||||
- 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-73, B-87, B-92, B-94), per i quattro fatti che
|
||||
escono dall'elenco · *Oggi:* anche l'ingresso in `drawing`, gli RBF
|
||||
impossibili, la registrazione e il cambio password riuscito non producono
|
||||
alcuna riga.
|
||||
|
||||
## Log applicativo
|
||||
|
||||
@@ -383,13 +690,16 @@ va letto sul server.
|
||||
`./data/logs` dell'host.
|
||||
*Codice:* `app/logging_config.py:setup_logging`, `docker-compose.yml`
|
||||
|
||||
- **[AUD-34]** Il logger radice ha **solo** l'handler su file: i messaggi dei
|
||||
moduli dell'applicazione (scheduler, listener, riconciliatori) finiscono in
|
||||
`data/logs/app.log` e non sull'output del container. `docker compose logs app`
|
||||
mostra solo ciò che uvicorn scrive con la propria configurazione (avvio,
|
||||
richieste) e l'output di avvio del container (controllo della chiave,
|
||||
migrazioni Alembic).
|
||||
- **[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
|
||||
|
||||
@@ -136,8 +136,11 @@ richiesta di scommessa (vedi
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`
|
||||
- **[RND-34]** **Impegno sul segreto**: all'apertura il server genera un
|
||||
segreto di 32 byte casuali (`server_seed`, da un generatore
|
||||
crittograficamente sicuro), lo salva sul round e pubblica solo la sua
|
||||
impronta `server_seed_hash = sha256(server_seed)`, esposta da
|
||||
crittograficamente sicuro), lo salva sul round **cifrato** con la chiave
|
||||
della master xprv (`server_seed_enc`, [modello-dati.md](modello-dati.md)
|
||||
DAT-64: il database non contiene mai il segreto in chiaro prima della
|
||||
rivelazione) e pubblica solo la sua impronta
|
||||
`server_seed_hash = sha256(server_seed)`, esposta da
|
||||
`GET /rounds/current` e dalla prova pubblica ([RND-37]) fin dall'apertura.
|
||||
`server_seed` non è esposto da nessun endpoint, `/admin` compreso, finché il
|
||||
vincitore non è salvato ([RND-18]).
|
||||
@@ -307,7 +310,10 @@ flowchart TB
|
||||
il quorum non concorda o non risponde abbastanza, si scrive
|
||||
`draw_header_corroboration_failed` (altezza e hash riportati) e al passo
|
||||
successivo si richiede **la stessa altezza**: il round non passa mai a un
|
||||
blocco diverso.
|
||||
blocco diverso. L'evento si scrive una volta per ogni hash diverso
|
||||
riportato dal server attivo per quell'altezza, non a ogni richiesta, così
|
||||
un disaccordo che dura non scrive una riga ogni 5 secondi; la durata
|
||||
dell'attesa resta visibile tramite `draw_stalled` ([RND-20]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` ·
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
|
||||
*Stato:* da implementare (B-73) · *Oggi:* si usa l'header della punta
|
||||
@@ -330,8 +336,11 @@ flowchart TB
|
||||
`draw_block_hash`), il seme (`seed_int`), il vincitore e il montepremi
|
||||
(`pool_amount_sats`), passa a `paying_out` e scrive `winner_drawn`
|
||||
(indirizzo del vincitore, montepremi, altezza, hash, numero di
|
||||
partecipanti, `participants_hash`). Solo **dopo** quel commit `server_seed`
|
||||
diventa pubblico ([RND-37]); si invia una notifica SSE. Da quel momento
|
||||
partecipanti, `participants_hash`, `draw_height`, `server_seed` e `seed`
|
||||
in esadecimale, [audit-log.md](audit-log.md) AUD-24). `server_seed` entra
|
||||
nel registro nella stessa transazione in cui il vincitore viene salvato, e
|
||||
diventa pubblico tramite la prova ([RND-37]) solo dopo quel commit; si invia
|
||||
una notifica SSE. Da quel momento
|
||||
chiunque, con i dati della prova pubblica e la catena, può rifare il calcolo
|
||||
e ottenere lo stesso vincitore (vedi
|
||||
[Come si verifica un'estrazione](#come-si-verifica-unestrazione)). Il primo
|
||||
@@ -506,7 +515,9 @@ del montepremi del round.
|
||||
`insufficient pool UTXOs`, il codice dell'errore di costruzione
|
||||
(`too_many_inputs`, `winner_share_below_network_fee`,
|
||||
`commission_below_dust_limit`), `payout build failed` per ogni altro errore
|
||||
di costruzione, `broadcast rejected`. Fanno eccezione due casi, che non
|
||||
di costruzione, `broadcast rejected`, e — *da implementare (B-82)* —
|
||||
`pool verification unavailable` quando la verifica del montepremi prima del
|
||||
pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non
|
||||
scrivono `payout_failed`: un pagamento già in volo per il round ([RND-26]),
|
||||
che termina il tentativo senza scrivere nulla, e un pagamento bloccato
|
||||
([RND-39]), che scrive `payout_blocked`. A ogni passo lo scheduler
|
||||
@@ -578,20 +589,28 @@ del montepremi del round.
|
||||
`insufficient pool UTXOs` a ogni tentativo, con i soldi di tutti gli altri
|
||||
bloccati.
|
||||
- **[RND-40]** **Configurazione pagabile**: `rounds_can_open` rifiuta una
|
||||
configurazione in cui un round con un solo partecipante non si potrebbe
|
||||
pagare. Con `netto` = `bet_amount_sats − estimate_vsize(MAX_TX_INPUTS, 2) ×
|
||||
fee_rate_sat_vb` (la scommessa più piccola che può arrivare al montepremi)
|
||||
devono valere entrambe:
|
||||
- `winner_share(netto) − estimate_vsize(1, 3) × fee_rate_sat_vb ≥
|
||||
configurazione in cui un round non si potrebbe pagare, verificandola per
|
||||
**n = 1** e per **n = `MAX_PARTICIPANTS_PER_ROUND`** partecipanti (i due
|
||||
estremi: con uno solo la commissione è minima, con il massimo è massima la
|
||||
riserva, che cresce con gli ingressi). Con `netto` = `bet_amount_sats −
|
||||
estimate_vsize(MAX_TX_INPUTS, 2) × fee_rate_sat_vb` (la scommessa più piccola
|
||||
che può arrivare al montepremi) e `montepremi = n × netto`, per entrambi i
|
||||
valori di n devono valere:
|
||||
- `winner_share(montepremi) − estimate_vsize(n, 3) × fee_rate_sat_vb ≥
|
||||
DUST_LIMIT_SATS`;
|
||||
- `netto − winner_share(netto) − riserva ≥ DUST_LIMIT_SATS`, con la
|
||||
riserva di [RND-38] calcolata su un ingresso.
|
||||
- `montepremi − winner_share(montepremi) − riserva(n) ≥ DUST_LIMIT_SATS`,
|
||||
con `riserva(n)` quella di [RND-38] calcolata su n ingressi.
|
||||
|
||||
Si usano i valori della configurazione al momento dell'apertura.
|
||||
*Codice:* `app/rounds/service.py:rounds_can_open` ·
|
||||
La stessa verifica si applica a `PUT /admin/config`: una modifica di
|
||||
`bet_amount_sats` o `fee_rate_sat_vb` che la farebbe fallire è rifiutata,
|
||||
così un aumento della commissione di rete a round in corso non può rendere
|
||||
impagabile il round (il pagamento legge `fee_rate_sat_vb` al momento della
|
||||
costruzione).
|
||||
*Codice:* `app/rounds/service.py:rounds_can_open`, `app/api/routes/admin.py` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo
|
||||
che `fee_address` non sia vuoto.
|
||||
che `fee_address` non sia vuoto, e `PUT /admin/config` non fa verifiche di
|
||||
pagabilità.
|
||||
|
||||
## Tempi reali
|
||||
|
||||
|
||||
@@ -320,6 +320,11 @@ Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*,
|
||||
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
|
||||
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
|
||||
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
[sicurezza.md](sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
||||
mancante o vuoto.
|
||||
|
||||
- **[DEC-12]** **Il server gira solo in Docker, in sviluppo come in
|
||||
produzione**, dietro Caddy (TLS automatico, certificato autofirmato su
|
||||
@@ -566,7 +571,10 @@ Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*,
|
||||
l'esito appena il blocco è noto: una collusione fra operatore e minatore
|
||||
resta possibile ed è accettata ([LIM-25]), perché l'operatore è già
|
||||
fidato come custode ([LIM-26]). `server_seed` non è esposto da nessun
|
||||
endpoint prima della rivelazione. Regole in
|
||||
endpoint prima della rivelazione, e nel database è cifrato con la chiave
|
||||
della master xprv finché non viene rivelato
|
||||
([sicurezza.md](sicurezza.md#sec-56) SEC-56): una copia del database da
|
||||
sola non basta a conoscerlo. Regole in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione),
|
||||
endpoint in [api.md](api.md) (B-73).
|
||||
|
||||
@@ -1344,7 +1352,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
vincitore.** Con il commit-reveal ([DEC-24]) l'operatore conosce
|
||||
`server_seed` e può calcolare l'esito di ogni blocco candidato; un minatore
|
||||
d'accordo con lui può scartare i blocchi sfavorevoli. Lo stesso vale per chi
|
||||
ruba `server_seed` dal database.
|
||||
ottiene sia il database sia la chiave di cifratura: il segreto è cifrato a
|
||||
riposo ([sicurezza.md](sicurezza.md#sec-56) SEC-56), quindi il solo
|
||||
database, o un suo backup, non basta.
|
||||
- *Impatto:* un round può essere vinto da un complice.
|
||||
- *Perché è accettato:* l'operatore è già fidato come custode di tutti i
|
||||
fondi ([LIM-26]); chi può leggere il database può già fare di peggio.
|
||||
|
||||
+89
-33
@@ -26,7 +26,15 @@ sola schermata di login.
|
||||
- **Round** — storico round: stato, vincitore, importi, txid di payout
|
||||
- **Transazioni pendenti** — bet/payout/prelievi non ancora confermati, candidati al fee-bump RBF
|
||||
- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.; catalogo completo in [audit-log.md](audit-log.md))
|
||||
- **Bug report** — segnalazioni inviate da `/report-bug`, da smistare `open` → `read` → `resolved`
|
||||
- **Segnalazioni bug** — segnalazioni inviate da `/report-bug`, da smistare `open` → `read` → `resolved`
|
||||
|
||||
Nella navbar, a destra, una pillola mostra lo stato del round ("In attesa
|
||||
del prossimo round", "Round aperto", oppure "Estrazione in corso", che copre
|
||||
chiusura, estrazione e pagamento) e l'altezza dell'ultimo blocco noto,
|
||||
aggiornate ogni 15 secondi da `GET /rounds/current`. Non indica se la
|
||||
connessione Electrum è attiva né a quale server: un'altezza che non sale per
|
||||
parecchi minuti è il segnale da guardare (procedura in
|
||||
[operativita.md](operativita.md), "Electrum irraggiungibile").
|
||||
|
||||
## Parametri
|
||||
|
||||
@@ -39,21 +47,24 @@ business — quelli si toccano solo da qui.
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: finché è vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Un round già in corso non viene interrotto se svuoti il campo: chiude, estrae e paga normalmente. |
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. |
|
||||
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
|
||||
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
|
||||
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
|
||||
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi in sequenza (conferma dell'ultima bet in sospeso, blocco dell'estrazione, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
|
||||
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
|
||||
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
|
||||
|
||||
Durata e cooldown si applicano **dal round successivo**, non a quello già in
|
||||
corso: ogni round si porta dietro i valori con cui è stato aperto, così
|
||||
abbassare la durata mentre un round è a metà non lo chiude di colpo, e alzarla
|
||||
non sposta il countdown che i giocatori stanno già guardando. Gli altri
|
||||
parametri (bet amount, fee rate, RBF timeout) restano invece a effetto
|
||||
immediato.
|
||||
|
||||
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
|
||||
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
|
||||
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
|
||||
non sposta il countdown che i giocatori stanno già guardando. Tutti gli altri
|
||||
parametri sono invece a effetto immediato, anche sul round in corso: un nuovo
|
||||
bet amount vale per le scommesse successive (il montepremi annunciato resta
|
||||
calcolato su quanto è stato davvero puntato), fee rate e RBF timeout valgono
|
||||
per le prossime transazioni costruite o accelerate, compreso il payout del
|
||||
round in corso, e il fee address vale per il prossimo payout costruito (vedi
|
||||
sopra).
|
||||
|
||||
Tutti gli importi in PLM vengono convertiti in sats (1 PLM = 100.000.000 sats)
|
||||
solo nella chiamata API — il backend lavora sempre in sats.
|
||||
@@ -74,30 +85,42 @@ sorprenderli con un'interruzione improvvisa.
|
||||
- **"Interrompi dopo questo round"**: il round eventualmente in corso viene
|
||||
**completato normalmente** — chiude, estrae il vincitore da un blocco
|
||||
confermato, e paga il 70/30 come sempre. Solo l'apertura del **round
|
||||
successivo** viene sospesa. Gli utenti vedono un avviso di manutenzione
|
||||
sulla loro dashboard (e sulla home, anche da sloggati) finché la lotteria
|
||||
resta in pausa.
|
||||
successivo** viene sospesa. La pausa non ferma nemmeno le scommesse e i
|
||||
prelievi sul round ancora aperto: il round prosegue fino alla sua scadenza.
|
||||
Gli utenti vedono un avviso di manutenzione sulla loro dashboard (e sulla
|
||||
home, anche da sloggati) finché la lotteria resta in pausa.
|
||||
- **"Riprendi lotteria"**: annulla la pausa — al prossimo giro dello
|
||||
scheduler (ogni 5 secondi) un nuovo round si apre normalmente (rispettando
|
||||
comunque il cooldown se il precedente si è appena chiuso).
|
||||
|
||||
Ogni pausa/ripresa viene registrata nell'audit log (`lottery_paused` /
|
||||
`lottery_resumed`), ma — come per il resto del pannello — non registra
|
||||
*quale* operatore l'ha premuta (token condiviso, vedi limiti noti in
|
||||
[CLAUDE.md](../CLAUDE.md)).
|
||||
*quale* operatore l'ha premuta (token condiviso, vedi
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
|
||||
Prima di un riavvio o di un deploy la pausa da sola non basta: bisogna anche
|
||||
aspettare che il round in corso sia `closed`, perché oggi un riavvio durante
|
||||
l'estrazione blocca il round. La procedura è in
|
||||
[running-the-server.md](running-the-server.md) e, completa, in
|
||||
[operativita.md](operativita.md) (OPS-10).
|
||||
|
||||
**Chi paga il fee-bump RBF?** Quando una bet, un payout o un prelievo resta
|
||||
troppo a lungo senza conferma (oltre il "Timeout prima del fee-bump RBF"), il
|
||||
sistema lo ritrasmette con una fee più alta. Il costo aggiuntivo lo assorbe
|
||||
sempre **chi ha originato la transazione**, non il destinatario: per bet e
|
||||
prelievi è l'utente stesso (gli torna un resto più piccolo), per i payout è
|
||||
il pool (il resto che torna all'indirizzo pool si riduce) — la quota del
|
||||
il pool (si riduce il resto che torna all'indirizzo pool) — la quota del
|
||||
vincitore e quella delle fee, già fissate, non vengono mai toccate. Se non
|
||||
c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce: la
|
||||
transazione resta in attesa finché non conferma da sola o finché il
|
||||
riconciliatore non la abbandona liberandone le monete (vedi
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
|
||||
[operativita.md](operativita.md)).
|
||||
c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce (nel
|
||||
log `could not bump pending_transaction N`, ripetuto ogni 30 secondi) e la
|
||||
transazione resta in attesa. **Per i payout è il caso normale**: il pool di
|
||||
un round contiene di solito esattamente le scommesse del round e il payout le
|
||||
spende tutte, quindi non ha resto e non si può accelerare. Una transazione
|
||||
ferma nel mempool non viene nemmeno abbandonata, perché il server la conosce:
|
||||
se è un payout, il round resta in `paying_out` e nessun nuovo round si apre
|
||||
finché non conferma. Cosa fare è in [operativita.md](operativita.md);
|
||||
il funzionamento in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||||
|
||||
## Round
|
||||
|
||||
@@ -108,27 +131,36 @@ txid.
|
||||
|
||||
## Transazioni pendenti
|
||||
|
||||
`GET /admin/pending-transactions` elenca bet, payout e prelievi ancora senza
|
||||
conferma: tipo, stato, txid corrente, fee rate usata, numero di tentativi
|
||||
(si incrementa a ogni bump RBF) e orario dell'ultima trasmissione. Una riga
|
||||
che resta qui a lungo, con `attempt_count` che sale, indica una transazione
|
||||
in difficoltà — vedi "Limiti noti" sul fallback RBF.
|
||||
`GET /admin/pending-transactions` elenca le ultime 50 transazioni tracciate
|
||||
(bet, payout e prelievi), dalla più recente, **in qualunque stato**: non solo
|
||||
quelle ancora `building` o `pending`, ma anche quelle già `confirmed` o
|
||||
`failed` — per sapere cosa è davvero in sospeso si guarda la colonna Stato.
|
||||
Per ogni riga: id, tipo, stato, txid corrente, fee rate usata, numero di
|
||||
tentativi (si incrementa a ogni bump RBF) e orario del **primo** invio (non
|
||||
cambia con i bump). Una riga che resta `pending` a lungo, con il numero di
|
||||
tentativi che sale, indica una transazione in difficoltà; una che resta
|
||||
`pending` con il numero di tentativi fermo è spesso un bump impossibile (vedi
|
||||
sopra "Chi paga il fee-bump RBF?").
|
||||
|
||||
## Audit log
|
||||
|
||||
`GET /admin/audit-log` elenca gli ultimi 200 eventi registrati dal sistema
|
||||
(tipo evento, payload JSON, utente/round coinvolti, timestamp): bet
|
||||
piazzate, payout inviati, round chiusi, configurazione modificata, accessi
|
||||
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
|
||||
cosa è successo dopo un problema.
|
||||
piazzate, payout inviati, round chiusi senza partecipanti, configurazione
|
||||
modificata, accessi alle chiavi private, ecc. È il primo posto da controllare
|
||||
per ricostruire cosa è successo dopo un problema. La sezione non ha filtri né
|
||||
ricerca, e l'endpoint accetta solo `limit` (al massimo 500): gli eventi più
|
||||
vecchi si leggono solo direttamente dal database. Non tutto viene
|
||||
registrato: per esempio registrazioni, cambi password fatti dagli utenti e
|
||||
bump RBF non lasciano righe (elenco in [audit-log.md](audit-log.md)).
|
||||
|
||||
Eventi a cui vale la pena prestare attenzione:
|
||||
|
||||
| Evento | Significato |
|
||||
|---|---|
|
||||
| `config_updated` | Un parametro è stato modificato; il payload contiene valore precedente e nuovo per ogni campo cambiato. |
|
||||
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
|
||||
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
|
||||
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | L'invio della transazione non è riuscito: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. Oggi l'evento viene scritto per **qualunque** errore d'invio, anche un timeout o una connessione caduta dopo l'invio, casi in cui la transazione potrebbe essere partita lo stesso: se il `reason` del payload non è un rifiuto esplicito della rete, controllare il `txid` con un explorer. |
|
||||
| `pending_tx_abandoned` | Il sistema ha dichiarato persa una transazione: UTXO liberati, bet rimossa o prelievo segnato `failed`. Oggi basta che il server Electrum attivo non la conosca (dopo 120 secondi per una riga `building`, 6 ore per una `pending`): dopo un riavvio o un cambio di server può essere un falso allarme, con la transazione ancora viva o già confermata. Controllare sempre il txid del payload con un explorer — procedura in [operativita.md](operativita.md) ("Transazione abbandonata"). |
|
||||
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
|
||||
| `bug_report_status_changed` | Un admin ha cambiato lo stato di una segnalazione (payload: `report_id`, stato precedente e nuovo). Con un token admin unico e condiviso, questa riga è l'unica traccia di chi tocca le segnalazioni. |
|
||||
| `payout_failed` | Un tentativo di payout di un round non è partito; il campo `reason` dice perché. Il round resta in `paying_out` e lo scheduler **riprova da solo al massimo una volta ogni 60 secondi**. Se l'evento si ripete, controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum (procedure in [operativita.md](operativita.md)). |
|
||||
@@ -160,9 +192,6 @@ un'altra catena (per esempio `bc1...`) sarebbe formalmente valido come witness
|
||||
program, e il 30% di commissione di ogni round finirebbe su uno script di cui
|
||||
nessuno ha la chiave.
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## Utenti, chiave privata e reset password
|
||||
|
||||
La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente
|
||||
@@ -195,7 +224,34 @@ per l'utente: solo un admin col token può farlo.
|
||||
round resta in `paying_out` e il payout viene ritentato ogni 60 secondi;
|
||||
alcuni motivi però non si risolvono da soli (vedi
|
||||
[operativita.md](operativita.md)).
|
||||
- Un riavvio mentre un round è in estrazione (`drawing`) lo blocca: prima
|
||||
di ogni riavvio mettere in pausa e aspettare `closed` (vedi
|
||||
"Manutenzione" sopra).
|
||||
- Elenco completo dei limiti accettati: [decisioni-e-limiti.md](decisioni-e-limiti.md).
|
||||
|
||||
Per l'elenco completo dei gap noti vedi la sezione "Known gaps / TODO" in
|
||||
`CLAUDE.md`.
|
||||
|
||||
## Cosa cambierà
|
||||
|
||||
La revisione del 2026-09-28 ha deciso delle correzioni non ancora
|
||||
implementate; quelle che cambiano ciò che si vede da questo pannello sono
|
||||
(dettaglio e ordine in [piano-correzioni.md](piano-correzioni.md)):
|
||||
|
||||
- **La pausa avrà un motivo.** Oltre a quella manuale, il sistema metterà in
|
||||
pausa la lotteria da solo per una riorganizzazione profonda della catena,
|
||||
per un payout bloccato o per una transazione data per persa e poi
|
||||
confermata; il pannello mostrerà il motivo, e "Riprendi lotteria" resterà
|
||||
l'unico modo di uscirne, dopo le verifiche di
|
||||
[operativita.md](operativita.md) (OPS-30).
|
||||
- **Payout bloccato.** Se la scommessa del vincitore sparisce dalla catena,
|
||||
il payout non partirà (evento `payout_blocked`) e il round resterà in
|
||||
`paying_out` fino alla ripresa; i partecipanti la cui scommessa è sparita
|
||||
verranno esclusi dal montepremi (`participant_reorged_out`).
|
||||
- **Stato Electrum.** Il pannello mostrerà se la connessione è attiva, il
|
||||
server in uso e da quanto non arriva un blocco.
|
||||
- **Audit log consultabile per intero**, con filtri per tipo di evento,
|
||||
round, utente e intervallo di tempo, e a pagine; nuovi eventi come
|
||||
`user_registered`, `password_changed`, `rbf_bump_failed`,
|
||||
`closing_stalled` e `chain_reorg_detected` (catalogo in
|
||||
[audit-log.md](audit-log.md)).
|
||||
+29
-16
@@ -103,7 +103,8 @@ erDiagram
|
||||
string fee_address "snapshot (da implementare)"
|
||||
int draw_confirmations "snapshot (da implementare)"
|
||||
string pool_address "da implementare"
|
||||
string server_seed "segreto fino al vincitore (da implementare)"
|
||||
string server_seed_enc "cifrato fino alla rivelazione (da implementare)"
|
||||
string server_seed "in chiaro solo dopo la rivelazione (da implementare)"
|
||||
string server_seed_hash "da implementare"
|
||||
string participants_hash "da implementare"
|
||||
int draw_height "da implementare"
|
||||
@@ -439,7 +440,8 @@ restano nella tabella come storico.
|
||||
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
|
||||
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` |
|
||||
| `pool_address` | String(128) | no | backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) — *da implementare (B-80)* | `open_new_round_if_needed` |
|
||||
| `server_seed` | String(64) | sì | `NULL` | Segreto di 32 byte in esadecimale, generato all'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` |
|
||||
| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` |
|
||||
| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` |
|
||||
| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` |
|
||||
| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) — *da implementare (B-73, B-81)* | `RoundScheduler._close_and_draw` |
|
||||
| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) — *da implementare (B-73, B-81)* | `_close_and_draw` |
|
||||
@@ -582,16 +584,26 @@ restano nella tabella come storico.
|
||||
leggono `bet_amount_sats` e `fee_address` da `round_config` al momento
|
||||
dell'uso, quindi una modifica a metà round cambia il round in corso.
|
||||
|
||||
- **[DAT-64]** `server_seed` e `server_seed_hash` si scrivono insieme,
|
||||
all'inserimento del round: `server_seed` sono 32 byte casuali generati con
|
||||
`secrets.token_bytes(32)`, in esadecimale minuscolo (64 caratteri);
|
||||
`server_seed_hash` è `sha256` di quei 32 byte, in esadecimale minuscolo.
|
||||
Nessuno dei due cambia più. `server_seed` è **segreto** finché
|
||||
`winner_user_id` non è stato scritto: nessun endpoint lo restituisce prima,
|
||||
e nessuna voce del registro di audit lo contiene prima
|
||||
([api.md](api.md), `GET /rounds/{id}/proof`). I round aperti prima della
|
||||
migrazione hanno entrambe le colonne a `NULL`.
|
||||
- **[DAT-64]** Il segreto del round sono 32 byte casuali generati con
|
||||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||||
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
||||
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
|
||||
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
||||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||||
transazione che scrive `winner_user_id`, oppure alla chiusura di un round
|
||||
senza partecipanti. Così una copia del database o un suo backup, senza la
|
||||
chiave, non rivela il segreto di un round ancora aperto. Nessun endpoint e
|
||||
nessuna voce del registro di audit contengono il segreto prima della
|
||||
rivelazione ([api.md](api.md), `GET /rounds/{id}/proof`). Nessuna colonna
|
||||
cambia più dopo la rivelazione; `server_seed_enc` resta com'è. Se
|
||||
all'avvio la chiave non decifra il `server_seed_enc` di un round non
|
||||
ancora rivelato, il server rifiuta di partire
|
||||
([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della
|
||||
migrazione hanno le tre colonne a `NULL`.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
`app/api/routes/rounds.py:current_round` · *Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* le colonne non esistono; il seme
|
||||
è l'hash del blocco da solo.
|
||||
@@ -846,7 +858,7 @@ senza riavviare (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
| `operator` | `app/api/routes/admin.py:pause_lottery`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md) B-90) | pausa decisa dall'operatore |
|
||||
| `reorg` | il listener Electrum | riorganizzazione di profondità ≥ 2 ([connessione-electrum.md](connessione-electrum.md) B-85) |
|
||||
| `payout_blocked` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | la scommessa del vincitore non è più sulla catena ([DAT-68], B-82) |
|
||||
| `resurrected_tx` | `app/tx/confirmation.py:poll_once` | una riga `failed` risulta confermata ([DAT-72], B-78) |
|
||||
| `resurrected_tx` | `app/tx/reconcile.py:reconcile_once` | una riga `failed` risulta confermata ([DAT-72], B-78) |
|
||||
|
||||
Una pausa automatica (`reorg`, `payout_blocked`, `resurrected_tx`) scrive
|
||||
sempre il proprio motivo, anche sopra una pausa già attiva; una pausa
|
||||
@@ -859,7 +871,7 @@ senza riavviare (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
`app/api/routes/admin.py:resume_lottery` ·
|
||||
`app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/confirmation.py:poll_once` · *Test:* —
|
||||
`app/tx/reconcile.py:reconcile_once` · *Test:* —
|
||||
*Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* esiste solo
|
||||
`paused`, scritto solo dai due endpoint admin.
|
||||
|
||||
@@ -952,7 +964,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||||
| `building`, `pending` | `failed` | `app/tx/reconcile.py:_abandon`, solo con una prova ([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-78) |
|
||||
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal`, solo per un rifiuto esplicito |
|
||||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet`, solo per un rifiuto esplicito |
|
||||
| `failed` | `confirmed` | `app/tx/confirmation.py:poll_once` ([DAT-72]) |
|
||||
| `failed` | `confirmed` | `app/tx/reconcile.py:reconcile_once` ([DAT-72]) |
|
||||
|
||||
Un broadcast dall'esito ambiguo lascia la riga in `building` per il
|
||||
riconciliatore, per tutti e tre i `kind` (B-76). Il poller delle conferme
|
||||
@@ -1016,7 +1028,8 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||||
`"not found on chain"` e `failed_at` non esiste.
|
||||
|
||||
- **[DAT-72]** Una riga `failed` con `failed_at` negli ultimi 14 giorni resta
|
||||
tra quelle che il poller delle conferme controlla, su tutti i txid di
|
||||
tra quelle che il riconciliatore controlla a ogni giro (120 s,
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) TX-23), su tutti i txid di
|
||||
`pending_transaction_txids`. Se una versione risulta confermata, la riga
|
||||
passa a `confirmed`, si scrive `pending_tx_resurrected` e la lotteria va in
|
||||
pausa con `paused_reason = 'resurrected_tx'` ([DAT-69]), tutto nella stessa
|
||||
@@ -1024,7 +1037,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||||
(partecipante cancellato, prelievo `failed`, input liberati) **non** vengono
|
||||
ricostruite automaticamente: le riallinea l'operatore
|
||||
([operativita.md](operativita.md) B-78).
|
||||
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:_abandon` ·
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_abandon` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene mai
|
||||
più letta.
|
||||
|
||||
+9
-2
@@ -120,7 +120,10 @@ oggi la variabile, separata dalla master key.
|
||||
controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i
|
||||
fondi di tutti gli utenti e del montepremi. In alternativa si può conservare
|
||||
offline la xprv in chiaro, ottenuta con `scripts/decrypt_master_key.py`: in
|
||||
quel caso quella copia da sola è la chiave di tutto.
|
||||
quel caso quella copia da sola è la chiave di tutto. Dopo B-73 la stessa
|
||||
chiave protegge anche il segreto dell'estrazione dei round aperti
|
||||
([sicurezza.md](sicurezza.md#sec-56) SEC-56): è un motivo in più per non
|
||||
conservarla insieme ai backup del database ([OPS-06]).
|
||||
*Codice:* `app/wallet/keystore.py:decrypt_xprv`,
|
||||
`scripts/decrypt_master_key.py`
|
||||
|
||||
@@ -754,7 +757,11 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
leggerebbe il file vecchio con la chiave nuova e fallirebbe alla prima
|
||||
derivazione. Per questo nessun passo usa il venv.
|
||||
1. Pausa, attesa di `closed` e backup come in [OPS-10], poi
|
||||
`docker compose stop app`. Tenere una copia del file attuale per poter
|
||||
`docker compose stop app`. Dopo B-73 questo passo garantisce anche che
|
||||
nessun round abbia il segreto dell'estrazione ancora cifrato con la
|
||||
chiave vecchia ([sicurezza.md](sicurezza.md#sec-56) SEC-56): tutti i
|
||||
round `closed` sono già rivelati, e il round successivo cifrerà il suo
|
||||
con la chiave nuova. Tenere una copia del file attuale per poter
|
||||
tornare indietro: `cp data/keys/master.xprv.enc data/keys/master.xprv.enc.old`.
|
||||
2. Annotare gli indirizzi derivati con la chiave vecchia:
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,39 @@ chiunque usi PLM, non solo la lotteria. Queste misure fanno sì che non
|
||||
convenga e che non blocchi la lotteria; non lo rendono impossibile. Vedi
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md).
|
||||
|
||||
### Decisioni confermate il 2026-09-29
|
||||
|
||||
Scelte di dettaglio emerse scrivendo la specifica, confermate prima di
|
||||
iniziare l'implementazione:
|
||||
|
||||
1. **Il segreto dell'estrazione è cifrato a riposo.** Fino alla rivelazione
|
||||
il database contiene `server_seed` solo cifrato con la chiave della master
|
||||
xprv, così una copia del database o un backup non bastano a prevedere il
|
||||
vincitore di un round aperto. All'avvio il server verifica di poterlo
|
||||
decifrare.
|
||||
→ [sicurezza.md](sicurezza.md#sec-56) SEC-56,
|
||||
[modello-dati.md](modello-dati.md) DAT-64, B-73
|
||||
2. **Soglie di protezione:**
|
||||
- cambio password: dal 5° tentativo sbagliato attese che raddoppiano da 2
|
||||
a 300 secondi, azzerate dopo 900 secondi senza errori o a un cambio
|
||||
riuscito ([sicurezza.md](sicurezza.md) SEC-54);
|
||||
- segnalazioni di bug: al massimo 10 all'ora per IP
|
||||
([sicurezza.md](sicurezza.md) SEC-55);
|
||||
- verifica della chiave all'avvio: indirizzo di riserva, i 10 utenti con
|
||||
indice più basso e i 10 con indice più alto, gli ultimi 10 round con
|
||||
indirizzo proprio, e il segreto di ogni round non ancora rivelato
|
||||
([sicurezza.md](sicurezza.md#sec-49) SEC-49).
|
||||
→ B-89, B-94
|
||||
3. **Un rifiuto dell'invio si verifica prima di liberare le monete.** Il
|
||||
testo degli errori Electrum non distingue in modo affidabile un rifiuto da
|
||||
un "già in mempool", e il protocollo non ha un codice standard. Dopo ogni
|
||||
rifiuto si chiede la transazione a tutti i server: basta che uno la conosca
|
||||
perché l'invio valga come accettato, e si annulla solo se nessuno la
|
||||
conosce. Il riconoscimento del testo resta una scorciatoia, non una
|
||||
condizione di sicurezza.
|
||||
→ [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#invio-rifiutato)
|
||||
TX-04, B-76
|
||||
|
||||
## Ordine di implementazione
|
||||
|
||||
Le fasi vanno eseguite in ordine: ognuna si appoggia sulle precedenti.
|
||||
@@ -106,6 +139,7 @@ test di regressione, secondo [processo-di-modifica.md](processo-di-modifica.md).
|
||||
|---|---|---|
|
||||
| B-94 | Limiti su cambio password, login, bug report; errori strutturati | media |
|
||||
| B-96 | Incoerenze minori (sette voci, un test ciascuna) | media |
|
||||
| B-98 | Selezione delle monete che garantisce il resto quando il saldo lo permette | media |
|
||||
|
||||
### Fase 7 — Prova su rete reale
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Avviare il server
|
||||
|
||||
Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto,
|
||||
master key generata, migrazioni applicate).
|
||||
master key generata). Le migrazioni del database non si applicano a mano: le
|
||||
esegue il container a ogni avvio, prima di far partire il server.
|
||||
|
||||
Il server gira sempre via Docker, in sviluppo e in produzione allo stesso
|
||||
modo — non esiste un modo supportato per lanciare `uvicorn` direttamente.
|
||||
@@ -33,10 +34,28 @@ sicurezza al primo accesso (normale, accettalo o usa `curl -k`).
|
||||
|
||||
### Modalità produzione, con dominio reale
|
||||
|
||||
Scrivere il dominio in `.env`, accanto agli altri valori:
|
||||
|
||||
```bash
|
||||
SITE_ADDRESS=lottery.tuodominio.it docker compose up -d
|
||||
# .env
|
||||
SITE_ADDRESS=lottery.tuodominio.it
|
||||
```
|
||||
|
||||
e avviare come sempre:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Docker Compose legge `.env` da solo per sostituire `${SITE_ADDRESS}` in
|
||||
`docker-compose.yml`, quindi ogni avvio successivo usa lo stesso dominio.
|
||||
**Non** passarlo solo sulla riga di comando
|
||||
(`SITE_ADDRESS=… docker compose up -d`): il valore vale per quel comando e
|
||||
basta, e il primo `docker compose up -d --build` lanciato senza di esso
|
||||
riporta Caddy a `localhost` con un certificato autofirmato — il sito pubblico
|
||||
smette di rispondere con un certificato valido. Per controllare:
|
||||
`grep '^SITE_ADDRESS=' .env`.
|
||||
|
||||
Il DNS del dominio deve già puntare all'IP del server, con le porte 80 e 443
|
||||
raggiungibili da internet. Caddy richiede e rinnova automaticamente un
|
||||
certificato Let's Encrypt reale — nessuna configurazione aggiuntiva.
|
||||
@@ -44,12 +63,22 @@ certificato Let's Encrypt reale — nessuna configurazione aggiuntiva.
|
||||
### Comandi utili
|
||||
|
||||
```bash
|
||||
docker compose logs -f app # segui i log dell'app (anche in ./data/logs/app.log)
|
||||
tail -f data/logs/app.log # log dell'applicazione (scheduler, Electrum, riconciliatori, errori)
|
||||
docker compose logs -f app # solo output di avvio del container e di uvicorn (vedi sotto)
|
||||
docker compose ps # stato dei container
|
||||
docker compose stop # ferma senza rimuovere i container
|
||||
docker compose down # ferma e rimuove i container (i dati in ./data/ restano)
|
||||
```
|
||||
|
||||
**Dove sono i log.** I messaggi dell'applicazione — connessione Electrum,
|
||||
scheduler dei round, riconciliatori, errori delle richieste — sono scritti
|
||||
**solo** in `./data/logs/app.log` (con rotazione: `app.log.1` … `app.log.5`),
|
||||
non sull'output del container. `docker compose logs -f app` mostra soltanto
|
||||
l'output di avvio del container (controllo della master key, migrazioni
|
||||
Alembic) e ciò che uvicorn scrive per conto suo (avvio, richieste HTTP): per
|
||||
capire cosa sta facendo la lotteria si legge il file. Dettagli e messaggi da
|
||||
conoscere in [audit-log.md](audit-log.md#log-applicativo).
|
||||
|
||||
> **Nota sul `Caddyfile`**: è montato in sola lettura nel container `caddy`
|
||||
> (bind mount). Modificarlo non basta a farlo ripartire con la nuova
|
||||
> configurazione — `docker compose up -d --build` non ricrea `caddy` solo
|
||||
@@ -61,6 +90,29 @@ docker compose down # ferma e rimuove i container (i dati in ./data/
|
||||
> (oppure, senza interrompere le connessioni esistenti: `docker compose exec
|
||||
> caddy caddy reload --config /etc/caddy/Caddyfile`).
|
||||
|
||||
### Riavvii e deploy: prima la pausa
|
||||
|
||||
Oggi l'arresto del container non è pulito: con una pagina `/` aperta (uno
|
||||
stream SSE attivo) uvicorn aspetta che lo stream si chiuda e Docker uccide il
|
||||
processo dopo 10 secondi. Per il codice ogni `docker compose up -d --build`,
|
||||
`restart` o `down` è quindi un crash: durante `drawing` blocca il round,
|
||||
durante un invio lascia una transazione `building` da riconciliare.
|
||||
|
||||
Prima di **qualunque** riavvio o deploy (nuova versione, modifica di `.env`,
|
||||
rotazione di un segreto):
|
||||
|
||||
1. fare il backup del database;
|
||||
2. in `/admin` → Parametri → Manutenzione, premere "Interrompi dopo questo
|
||||
round";
|
||||
3. aspettare che il round in corso arrivi a `closed` (`GET /rounds/current`
|
||||
senza più un round attivo) e che "Transazioni pendenti" non abbia righe
|
||||
`building`;
|
||||
4. riavviare, controllare in `data/logs/app.log` la riga
|
||||
`Electrum connected to …`, poi "Riprendi lotteria".
|
||||
|
||||
La procedura completa, con i comandi e il perché di ogni passo, è
|
||||
[OPS-10 in operativita.md](operativita.md#riavvio) ("Riavvio sicuro e deploy").
|
||||
|
||||
### ⚠️ Attenzione: riavvii automatici a metà round
|
||||
|
||||
`docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app:
|
||||
|
||||
@@ -68,9 +68,20 @@ Lo stato nel database non basta per la scadenza ([BET-04]) perché lo
|
||||
scheduler controlla ogni 5 secondi: fino a 5 secondi il round può risultare
|
||||
ancora `open` oltre la scadenza.
|
||||
|
||||
La selezione si ferma alla prima moneta che porta il totale a coprire il
|
||||
costo ([BET-08]): il resto di [BET-09] si misura su quelle monete, non
|
||||
sull'intero saldo.
|
||||
- **[BET-24]** **La selezione garantisce il resto quando il saldo lo
|
||||
permette.** Le monete si scelgono dalla più grande finché il totale copre il
|
||||
costo **più** 294 sat (`DUST_LIMIT_SATS`); [BET-09] rifiuta la scommessa solo
|
||||
se nemmeno tutte le monete disponibili (entro le 50 di [BET-08]) bastano.
|
||||
Con monete da 10 PLM e da 1 PLM, una scommessa da 10 PLM usa entrambe e
|
||||
lascia 1 PLM di resto. Lo stesso vale per il prelievo: la riduzione di
|
||||
[WDR-09] scatta solo quando nessuna combinazione di monete lascia un resto
|
||||
sufficiente.
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-98) · *Oggi:* la selezione si ferma alla prima
|
||||
moneta che copre il costo, e il resto si misura solo su quelle monete: con
|
||||
10 + 1 PLM una scommessa da 10 PLM è rifiutata con `balance_leaves_no_change`
|
||||
e un prelievo da 10 PLM viene ridotto invece di usare la seconda moneta.
|
||||
|
||||
- **[BET-21]** Il **costo** di una scommessa è il `bet_amount_sats`
|
||||
fotografato sul round alla sua apertura
|
||||
|
||||
+514
-125
@@ -1,11 +1,16 @@
|
||||
# Sicurezza
|
||||
|
||||
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e
|
||||
quali rischi accetta consapevolmente. Il documento descrive il codice così
|
||||
com'è: dove un meccanismo manca, lo dice.
|
||||
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, da chi
|
||||
si difende e quali rischi accetta consapevolmente. Il documento descrive il
|
||||
comportamento **atteso** dopo la revisione del 2026-09-28; i requisiti che il
|
||||
codice non soddisfa ancora sono marcati *da implementare*, con una riga
|
||||
*Oggi* che dice cosa fa il codice adesso (vedi
|
||||
[piano-correzioni.md](piano-correzioni.md) e
|
||||
[processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
|
||||
|
||||
Per la difesa contro server Electrum ostili (validazione degli header,
|
||||
quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md);
|
||||
per l'estrazione [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione);
|
||||
per gli endpoint e i codici di errore [api.md](api.md); per l'elenco degli
|
||||
eventi di audit [audit-log.md](audit-log.md); per backup e procedure
|
||||
operative [operativita.md](operativita.md); per le decisioni di progetto e i
|
||||
@@ -14,14 +19,17 @@ limiti accettati [decisioni-e-limiti.md](decisioni-e-limiti.md).
|
||||
## Modello di fiducia
|
||||
|
||||
La piattaforma è **custodiale**: tutte le chiavi private sono generate e
|
||||
conservate dal server. L'utente non controlla nulla finché non preleva.
|
||||
conservate dal server. L'utente non controlla nulla finché non preleva
|
||||
([DEC-01](decisioni-e-limiti.md), [LIM-26](decisioni-e-limiti.md)).
|
||||
|
||||
| Soggetto | Fiducia | Perché |
|
||||
|---|---|---|
|
||||
| Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. |
|
||||
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra (`.env`). |
|
||||
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra; separarle in file diversi con permessi stretti ([SEC-51], [SEC-53]) protegge da una copia sbagliata dei dati, non da chi controlla l'host. |
|
||||
| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. |
|
||||
| Server Electrum | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum: vedi [connessione-electrum.md](connessione-electrum.md#5-il-quorum). |
|
||||
| Server Electrum (attivo e di riserva) | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum di almeno 2 altri server: vedi [Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum). |
|
||||
| Chi produce i blocchi (minatori, anche con potenza affittata) | Nessuna | Non deve poter scegliere il vincitore né rendere un round impagabile: vedi [Chi produce e trasmette i blocchi](#chi-produce-e-trasmette-i-blocchi). |
|
||||
| Chi trasmette blocchi e transazioni (rete P2P, relay, il server attivo come relay) | Nessuna | Può ritardare, nascondere o riordinare ciò che annuncia; il blocco di estrazione è fissato per altezza e corroborato, e ogni transazione è inviata a tutti i server. |
|
||||
| Rete e client HTTP | Nessuna | TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy. |
|
||||
| Caddy (reverse proxy) | Totale | È l'unico proxy davanti all'app, e l'unica fonte di `X-Forwarded-For` di cui l'app si fida. |
|
||||
|
||||
@@ -30,28 +38,105 @@ conservate dal server. L'utente non controlla nulla finché non preleva.
|
||||
generata o conservata lato client.
|
||||
*Codice:* `app/wallet/hd.py:generate_master_key`, `app/wallet/hd.py:_load_account_key`
|
||||
|
||||
### Chi produce e trasmette i blocchi
|
||||
|
||||
L'hash di un blocco è l'unica fonte di casualità esterna al server, ed è
|
||||
prodotto da chi ha la potenza di calcolo. La potenza di PLM, circa 22 TH/s, si
|
||||
può affittare; scartare un blocco valido costa al minatore solo la
|
||||
ricompensa di quel blocco.
|
||||
|
||||
- **Perché il solo hash del blocco era manipolabile.** Se il seme è il solo
|
||||
hash del blocco, chi mina con biglietti in gioco conosce l'esito di ogni
|
||||
blocco che trova *prima* di pubblicarlo, e lo scarta finché uno non lo fa
|
||||
vincere. Chi trasmette i blocchi ha una leva analoga: il server Electrum
|
||||
attivo può non annunciare un blocco e far estrarre dal successivo; la
|
||||
corroborazione verificava che il blocco esistesse, non che fosse il primo
|
||||
dopo la chiusura ([LIM-13](decisioni-e-limiti.md)).
|
||||
- **Come il commit-reveal toglie il movente al minatore.** All'apertura il
|
||||
server si impegna su un segreto pubblicandone solo l'impronta
|
||||
([RND-34](ciclo-di-vita-del-round.md#0--apertura)); all'ingresso in
|
||||
`drawing` si fissano l'elenco dei partecipanti e l'altezza del blocco
|
||||
([RND-33](ciclo-di-vita-del-round.md#2--chiusura)); il seme combina
|
||||
segreto, hash del blocco a quell'altezza ed elenco, e dopo il salvataggio
|
||||
del vincitore il segreto è rivelato con la prova pubblica
|
||||
([RND-37](ciclo-di-vita-del-round.md#3--estrazione)). Un minatore che non
|
||||
conosce il segreto non sa chi vincerebbe con il suo blocco, quindi non ha
|
||||
motivo di scartarlo; un server che trasmette non può scegliere il blocco,
|
||||
perché l'altezza è fissata e l'header a quell'altezza deve essere
|
||||
confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)).
|
||||
*Oggi* (B-73): il seme è l'hash del primo blocco visto dopo la chiusura,
|
||||
senza segreto né impegno, e l'estrazione è manipolabile da chi mina.
|
||||
- **Header falsi.** Oggi l'unico controllo di lavoro su un header è che il
|
||||
suo hash rispetti il bersaglio che l'header stesso dichiara nel campo
|
||||
`bits`: poiché è chi fabbrica l'header a scegliere `bits`, un header mai
|
||||
minato con `bits = 0x207fffff` lo passa con probabilità 1/2. Dopo B-74 il
|
||||
bersaglio non può essere più facile del powLimit di PLM, `0x1d00ffff`
|
||||
([ELC-11](connessione-electrum.md#4-header-validati-non-creduti)), e ogni
|
||||
salto di più blocchi è verificato come catena. Anche al powLimit un header
|
||||
costa in media 2³² hash, pochi millisecondi a un ASIC: il controllo toglie
|
||||
la contraffazione gratuita, non quella economica. A proteggere l'estrazione
|
||||
sono il quorum sull'header di estrazione e il segreto del commit-reveal
|
||||
([LIM-14](decisioni-e-limiti.md)).
|
||||
|
||||
Restano **accettati**, e descritti in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md):
|
||||
|
||||
- **Operatore d'accordo con un minatore** ([LIM-25](decisioni-e-limiti.md)).
|
||||
L'operatore conosce il segreto prima della rivelazione e può calcolare
|
||||
l'esito di ogni blocco candidato; un minatore complice può scartare quelli
|
||||
sfavorevoli. È accettato perché l'operatore è già fidato come custode di
|
||||
tutti i fondi ([LIM-26](decisioni-e-limiti.md)): chi può leggere il segreto
|
||||
dal database può già spendere ogni moneta.
|
||||
- **Un attacco al 51% su PLM** ([LIM-24](decisioni-e-limiti.md)) è
|
||||
contenuto, non impedito: 6 conferme sui depositi
|
||||
([DEC-06](decisioni-e-limiti.md), B-82), rilevamento delle
|
||||
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
|
||||
del round prima del pagamento ([DEC-52](decisioni-e-limiti.md), B-82,
|
||||
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
|
||||
piattaforma. *Oggi:* depositi a 1 conferma, nessuna riorganizzazione
|
||||
rilevata, nessuna verifica prima del pagamento.
|
||||
|
||||
## Inventario dei segreti
|
||||
|
||||
| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione |
|
||||
|---|---|---|---|---|---|
|
||||
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza `XPRV_ENCRYPTION_KEY`; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
|
||||
| `XPRV_ENCRYPTION_KEY` | `.env` (variabile d'ambiente del container) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale: decifrare con la chiave vecchia, impostare la nuova, `encrypt_master_key.py --overwrite`. Non esiste uno script dedicato |
|
||||
| `JWT_SECRET` | `.env` | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
|
||||
| `ADMIN_TOKEN` | `.env`; nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
|
||||
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`), permessi `0600` ([SEC-51]) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza la chiave di cifratura; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
|
||||
| Chiave di cifratura della master key (Fernet) | File `./secrets/xprv_encryption.key` (permessi `0600`, fuori da `./data/`), montato in sola lettura come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da `XPRV_ENCRYPTION_KEY_FILE` ([SEC-53]) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale, con la procedura [OPS-22](operativita.md#segreto-compromesso). Non esiste uno script dedicato |
|
||||
| `JWT_SECRET` | `.env` (permessi `0600`) | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
|
||||
| `ADMIN_TOKEN` | `.env` (permessi `0600`); nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
|
||||
| Segreto del round (`server_seed`) | Tabella `rounds`, **cifrato** con la chiave della master xprv (`server_seed_enc`) fino alla rivelazione, poi in chiaro ([RND-34](ciclo-di-vita-del-round.md#0--apertura), [SEC-56]) | 32 byte da un generatore crittograficamente sicuro, all'apertura del round | Prima della rivelazione permette di calcolare il vincitore di ogni blocco candidato ([LIM-25](decisioni-e-limiti.md)) | L'estrazione di quel round non può concludersi | Uno nuovo per ogni round; pubblico dopo la rivelazione |
|
||||
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
||||
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
||||
|
||||
- **[SEC-02]** I segreti (`XPRV_ENCRYPTION_KEY`, `JWT_SECRET`, `ADMIN_TOKEN`)
|
||||
stanno solo in `.env`, mai nel database o nel repository; `.env`, i file
|
||||
*Oggi* (B-91, B-73): la chiave di cifratura è la variabile d'ambiente
|
||||
`XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect`
|
||||
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
|
||||
sistema (di norma `0644`, leggibili da tutti); il round non ha alcun segreto.
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le
|
||||
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
||||
l'host.
|
||||
|
||||
- **[SEC-02]** I segreti stanno fuori dal database e dal repository:
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` in `.env`, la chiave di cifratura della master
|
||||
key nel proprio file ([SEC-53]). `.env`, la cartella `secrets/`, i file
|
||||
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
||||
git sia dall'immagine Docker.
|
||||
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
||||
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
|
||||
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
|
||||
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
|
||||
`secrets/`.
|
||||
|
||||
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
||||
(`round_config`); in `.env` restano solo segreti e collegamenti
|
||||
infrastrutturali (database, percorso della master key, server Electrum,
|
||||
documentazione API). Cambiare un valore di `.env` richiede un riavvio.
|
||||
(`round_config`); in `.env` restano solo segreti, collegamenti
|
||||
infrastrutturali (database, percorso della master key, file della chiave
|
||||
di cifratura, server Electrum) e flag di avvio (`ENABLE_API_DOCS`,
|
||||
`ALLOW_NO_QUORUM`, `START_PAUSED`). Cambiare un valore di `.env` richiede un
|
||||
riavvio. L'elenco completo è in
|
||||
[architettura.md](architettura.md#configurazione).
|
||||
*Codice:* `app/config.py:Settings`
|
||||
*Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`,
|
||||
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono.
|
||||
|
||||
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
||||
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
||||
@@ -62,11 +147,12 @@ sovrascritte da `docker-compose.yml`.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
|
||||
I["encrypt_master_key.py<br/>xprv importata (input nascosto)"] --> E
|
||||
E["Cifratura Fernet<br/>con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc<br/>./data/keys/")]
|
||||
F --> L["Primo uso a runtime:<br/>decifratura e derivazione<br/>di m/84'/746'/0'"]
|
||||
I["encrypt_master_key.py<br/>xprv importata (input nascosto)<br/>confrontata con il database (SEC-50)"] --> E
|
||||
E["Cifratura Fernet<br/>con la chiave di XPRV_ENCRYPTION_KEY_FILE"] --> F[("master.xprv.enc<br/>./data/keys/, 0600")]
|
||||
F --> L["Avvio del server:<br/>decifratura, derivazione di m/84'/746'/0'<br/>e confronto con il database (SEC-49)"]
|
||||
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
|
||||
L --> P["Montepremi<br/>m/84'/746'/0'/1/0"]
|
||||
L --> P["Montepremi del round<br/>m/84'/746'/0'/2/round_id"]
|
||||
L --> R["Riserva del montepremi<br/>m/84'/746'/0'/1/0"]
|
||||
F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]
|
||||
```
|
||||
|
||||
@@ -76,28 +162,60 @@ flowchart TD
|
||||
API: `generate_master_key.py` genera un seed casuale di 32 byte, ne ricava
|
||||
la xprv (versione PLM mainnet), la cifra e la scrive in `MASTER_KEY_PATH`.
|
||||
Rifiuta di sovrascrivere un file esistente; entrambi gli script di
|
||||
creazione rifiutano di partire se `XPRV_ENCRYPTION_KEY` è vuota.
|
||||
creazione rifiutano di partire se la chiave di cifratura è vuota. I
|
||||
controlli sul database e i permessi del file scritto sono in [SEC-50].
|
||||
*Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py`
|
||||
|
||||
- **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la
|
||||
legge con input nascosto (`getpass`), verifica che sia una chiave estesa
|
||||
valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema.
|
||||
Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di
|
||||
versione xprv di Bitcoin. Sovrascrive un file esistente solo con
|
||||
`--overwrite`.
|
||||
I byte di versione non bastano a riconoscere una chiave PLM (PLM usa gli
|
||||
stessi byte di versione xprv di Bitcoin): a riconoscere la chiave giusta è
|
||||
il confronto con gli indirizzi già salvati ([SEC-50]). Sovrascrive un file
|
||||
esistente solo con `--overwrite`.
|
||||
*Codice:* `scripts/encrypt_master_key.py`
|
||||
|
||||
- **[SEC-50]** <a id="sec-50"></a>Gli script che scrivono la master key
|
||||
controllano il database indicato da `DATABASE_URL` prima di scrivere:
|
||||
`generate_master_key.py` rifiuta se il database contiene già utenti o
|
||||
round, perché una chiave nuova non ne riprodurrebbe gli indirizzi;
|
||||
`encrypt_master_key.py` ricalcola dalla xprv ricevuta gli indirizzi del
|
||||
campione di [SEC-49] e rifiuta, anche con `--overwrite`, se uno solo
|
||||
differisce da quello salvato. Un database assente o vuoto non ha nulla da
|
||||
confrontare e non blocca. Entrambi gli script creano il file con permessi
|
||||
`0600` (senza passare da permessi più larghi) e lo riscrivono con gli
|
||||
stessi permessi.
|
||||
*Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`,
|
||||
`app/wallet/hd.py:generate_master_key` · *Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque
|
||||
xprv senza guardare il database e scrivono il file con i permessi
|
||||
predefiniti del sistema (di norma `0644`).
|
||||
|
||||
### Cifratura a riposo
|
||||
|
||||
- **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con
|
||||
**Fernet** (AES-128-CBC + HMAC-SHA256) usando `XPRV_ENCRYPTION_KEY`. La xprv
|
||||
in chiaro non viene mai scritta su disco dal server.
|
||||
**Fernet** (AES-128-CBC + HMAC-SHA256) usando la chiave di cifratura
|
||||
([SEC-53]). La xprv in chiaro non viene mai scritta su disco dal server.
|
||||
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv`
|
||||
|
||||
La cifratura protegge da una copia del solo file (un backup di `data/`
|
||||
finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di
|
||||
decifratura sta nel `.env` accanto. Gli script non impostano permessi
|
||||
restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
decifratura sta sullo stesso host. Tenerla fuori da `./data/` ([SEC-53]) fa
|
||||
sì che un backup dei dati non contenga entrambe.
|
||||
|
||||
- **[SEC-53]** <a id="sec-53"></a>La chiave di cifratura della master key è
|
||||
un **file**, `./secrets/xprv_encryption.key` sull'host (cartella `0700`,
|
||||
file `0600`, fuori da `./data/`), montato in sola lettura come secret di
|
||||
compose in `/run/secrets/xprv_encryption_key`; l'app legge il percorso da
|
||||
`XPRV_ENCRYPTION_KEY_FILE` ([architettura.md](architettura.md#configurazione),
|
||||
ARC-05, ARC-30). La chiave non compare in alcuna variabile d'ambiente del
|
||||
container, quindi `docker inspect` non la mostra, e non finisce in un
|
||||
backup di `./data/`.
|
||||
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
|
||||
`docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
|
||||
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
|
||||
visibile con `docker inspect`.
|
||||
|
||||
### Derivazione
|
||||
|
||||
@@ -108,19 +226,26 @@ restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
`app/wallet/plm_network.py` ·
|
||||
*Test:* `tests/unit/test_hd.py`
|
||||
|
||||
- **[SEC-08]** La chiave del montepremi è `m/84'/746'/0'/1/0` (ramo 1
|
||||
dell'account, indice 0): sta nella stessa master key, non è un segreto
|
||||
separato. Non esiste un endpoint che ne esporti la chiave privata.
|
||||
- **[SEC-08]** Le chiavi del montepremi — una per round,
|
||||
`m/84'/746'/0'/2/<round_id>`, e quella di riserva, `m/84'/746'/0'/1/0`
|
||||
([REG-14](registrazione-e-depositi.md#il-conto-del-montepremi)) — stanno
|
||||
nella stessa master key, non sono segreti separati. Non esiste un endpoint
|
||||
che ne esporti la chiave privata.
|
||||
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`
|
||||
*Stato:* da implementare (B-80), per le chiavi per round · *Oggi:* esiste
|
||||
una sola chiave del montepremi, `m/84'/746'/0'/1/0`, per tutti i round.
|
||||
|
||||
### Caricamento a runtime
|
||||
|
||||
- **[SEC-09]** La master key viene letta e decifrata **al primo uso** (la
|
||||
prima derivazione, tipicamente una registrazione o il primo pagamento), non
|
||||
all'avvio. Da quel momento la chiave dell'account `m/84'/746'/0'` resta in
|
||||
memoria nel processo per tutta la sua durata; la root xprv non viene
|
||||
conservata.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`
|
||||
- **[SEC-09]** La master key viene letta e decifrata **all'avvio**, nel
|
||||
lifespan, prima di creare le attività in background, ed è subito
|
||||
verificata ([SEC-49]). Da quel momento la chiave dell'account
|
||||
`m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la
|
||||
root xprv non viene conservata.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan`
|
||||
*Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo
|
||||
uso (la prima derivazione, tipicamente una registrazione o il primo
|
||||
pagamento), non all'avvio.
|
||||
|
||||
### Recupero
|
||||
|
||||
@@ -132,32 +257,67 @@ restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
|
||||
## Regole di avvio
|
||||
|
||||
L'ordine completo dei controlli di avvio è in
|
||||
[architettura.md](architettura.md#nel-lifespan) (ARC-19, ARC-60); qui ci sono
|
||||
quelli di sicurezza. Il rifiuto di partire con meno di 2 server Electrum di
|
||||
riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
||||
[Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum)).
|
||||
|
||||
- **[SEC-11]** Il container rifiuta di partire se il file della master key non
|
||||
esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni.
|
||||
*Codice:* `Dockerfile` (comando `CMD`)
|
||||
|
||||
- **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha
|
||||
meno di 32 caratteri o se `XPRV_ENCRYPTION_KEY` è vuota; tutti i problemi
|
||||
sono riportati insieme in un unico `ConfigError`. Il controllo è nel
|
||||
lifespan, non nella classe `Settings`, perché test e script importano la
|
||||
configurazione senza avere segreti reali.
|
||||
- **[SEC-12]** <a id="sec-12"></a>All'avvio il server **rifiuta di servire**
|
||||
se `JWT_SECRET` ha meno di 32 caratteri o se la chiave di cifratura della
|
||||
master key è vuota (il file indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o
|
||||
è vuoto); tutti i problemi sono riportati insieme in un unico
|
||||
`ConfigError`. Il controllo è nel lifespan, non nella classe `Settings`,
|
||||
perché test e script importano la configurazione senza avere segreti
|
||||
reali.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
|
||||
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
|
||||
|
||||
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||||
risulta semplicemente chiuso (vedi [SEC-24]).
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
|
||||
Il controllo di avvio verifica che `XPRV_ENCRYPTION_KEY` non sia vuota, non
|
||||
che sia una chiave Fernet valida né che decifri davvero il file: una chiave
|
||||
malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]).
|
||||
- **[SEC-49]** <a id="sec-49"></a>Dopo [SEC-12], il server decifra la master
|
||||
key ([SEC-09]) e ne ricava gli indirizzi di un
|
||||
campione fisso, confrontandoli con quelli salvati nel database:
|
||||
- l'indirizzo di riserva `m/84'/746'/0'/1/0`, con il `pool_address` di ogni
|
||||
round che lo usa;
|
||||
- gli indirizzi degli utenti con i 10 `derivation_index` più bassi e i 10
|
||||
più alti (tutti, se gli utenti sono meno di 20), con `users.address`;
|
||||
- gli indirizzi `m/84'/746'/0'/2/<round_id>` degli ultimi 10 round per `id`
|
||||
che hanno un indirizzo proprio, con il loro `pool_address`;
|
||||
- il `server_seed_enc` di ogni round non ancora rivelato, che deve
|
||||
decifrarsi e avere `sha256` uguale al suo `server_seed_hash` ([SEC-56]).
|
||||
|
||||
Se la chiave non si decifra (chiave di cifratura sbagliata o malformata,
|
||||
file corrotto) o **un solo** indirizzo differisce, il server **rifiuta di
|
||||
servire**: il lifespan scrive nel log quale controllo è fallito (tipo e
|
||||
indice o id, mai la chiave) e il processo termina. Un database senza
|
||||
utenti né round non ha nulla da confrontare: basta che la chiave si
|
||||
decifri. Lo scopo è non partire con una master key diversa da quella che
|
||||
ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il
|
||||
montepremi e farebbe firmare transazioni che la rete rifiuta.
|
||||
*Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`,
|
||||
`app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla;
|
||||
una chiave di cifratura sbagliata o malformata emerge solo al primo uso
|
||||
della master key (prima registrazione o primo pagamento), e una xprv
|
||||
diversa da quella originale non emerge affatto: produce indirizzi nuovi,
|
||||
compreso quello del montepremi.
|
||||
|
||||
## Autenticazione degli utenti
|
||||
|
||||
Il flusso di registrazione e login è descritto in
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione);
|
||||
qui ci sono le regole di sicurezza.
|
||||
Il flusso di registrazione, login e cambio password è descritto in
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione),
|
||||
che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
|
||||
### Password
|
||||
|
||||
@@ -181,37 +341,45 @@ qui ci sono le regole di sicurezza.
|
||||
*Codice:* `app/auth/security.py:verify_password` ·
|
||||
*Test:* `tests/unit/test_security.py`
|
||||
|
||||
- **[SEC-17]** Lunghezza minima della password: 8 caratteri
|
||||
(`MIN_PASSWORD_LENGTH`), la stessa costante per registrazione e cambio
|
||||
password. Alla registrazione la password ha anche un massimo di 256
|
||||
caratteri; il cambio password e il login non impongono un massimo.
|
||||
- **[SEC-17]** Soglie delle credenziali: una password nuova ha almeno 8
|
||||
caratteri (`MIN_PASSWORD_LENGTH`, la stessa costante per registrazione e
|
||||
cambio password), e **ogni** campo di credenziali — username e password
|
||||
alla registrazione e al login, password attuale e nuova al cambio
|
||||
password — ha al massimo 256 caratteri. Il massimo limita il lavoro di
|
||||
Argon2 e la memoria che una sola richiesta può chiedere. Come ciascun
|
||||
endpoint le applica, e con quale errore, è in
|
||||
[REG-01](registrazione-e-depositi.md#registrazione),
|
||||
[REG-09](registrazione-e-depositi.md#sessione-e-password) e
|
||||
[REG-12](registrazione-e-depositi.md#sessione-e-password).
|
||||
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
|
||||
`app/api/routes/users.py:change_password` ·
|
||||
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per il massimo su login e cambio password ·
|
||||
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
|
||||
i campi del login e del cambio password non hanno limite di lunghezza.
|
||||
|
||||
- **[SEC-18]** Lo username è di 3–32 caratteri, solo `A-Z a-z 0-9 _ . -`, e
|
||||
**non distingue maiuscole e minuscole**: l'unicità è garantita da un indice
|
||||
unico su `lower(username)` e registrazione e login confrontano
|
||||
`lower(...)`. Il nome resta salvato come digitato.
|
||||
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:register`,
|
||||
`app/auth/routes.py:login`, `app/db/models.py:User` ·
|
||||
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
|
||||
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
|
||||
username, confronto senza maiuscole e indice unico su `lower(username)` sono
|
||||
regole del dominio, con la motivazione in [DEC-38](decisioni-e-limiti.md);
|
||||
qui non resta nulla di specifico della sicurezza.
|
||||
|
||||
- **[SEC-19]** Login con username inesistente e con password errata danno la
|
||||
stessa risposta ([REG-09](registrazione-e-depositi.md#sessione-e-password))
|
||||
e alimentano lo stesso contatore di tentativi ([SEC-30]). Due canali
|
||||
rivelano comunque se uno username esiste, e sono accettati: se lo username
|
||||
non esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno
|
||||
tempi di risposta diversi; la registrazione risponde per costruzione
|
||||
`username_taken` (409) a uno username occupato.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:register` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
|
||||
- **[SEC-19]** Il login risponde allo stesso modo (401 `invalid_credentials`)
|
||||
sia per uno username inesistente sia per una password errata, e i due casi
|
||||
alimentano lo stesso contatore di tentativi. Nota: se lo username non
|
||||
esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi
|
||||
di risposta diversi. La registrazione, invece, rivela per costruzione se uno
|
||||
username è occupato (409 `username_taken`).
|
||||
*Codice:* `app/auth/routes.py:login` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
|
||||
- **[SEC-20]** Non esiste recupero della password né reset self-service
|
||||
(nessuna email è raccolta). Un utente autenticato può *cambiarla* solo
|
||||
fornendo quella attuale; altrimenti serve il reset dall'operatore
|
||||
([SEC-27]).
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
- ~~[SEC-20]~~ Assorbito da [REG-12](registrazione-e-depositi.md#sessione-e-password)
|
||||
e [REG-13](registrazione-e-depositi.md#sessione-e-password): l'assenza di
|
||||
recupero, il cambio con la password attuale e il reset dall'operatore sono
|
||||
il flusso del dominio (motivazione in [DEC-37](decisioni-e-limiti.md)); la
|
||||
parte di sicurezza, cioè il freno sui tentativi, è [SEC-54], e il reset è
|
||||
[SEC-27].
|
||||
|
||||
### Token di sessione (JWT)
|
||||
|
||||
@@ -230,10 +398,15 @@ qui ci sono le regole di sicurezza.
|
||||
dell'operatore incrementano `token_version`, invalidando ogni sessione
|
||||
precedente; il cambio password restituisce un token nuovo, così la sessione
|
||||
corrente resta attiva. Un token privo di `tv` (emesso prima della sua
|
||||
introduzione) vale come `tv = 0`.
|
||||
introduzione) vale come `tv = 0`. Una richiesta senza header
|
||||
`Authorization` risponde 401 `not_authenticated`
|
||||
([API-06](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
|
||||
`app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
|
||||
l'header assente risponde 401 con il corpo non strutturato di FastAPI
|
||||
`{"detail": "Not authenticated"}`.
|
||||
|
||||
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
|
||||
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
|
||||
@@ -250,10 +423,13 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
|
||||
- **[SEC-24]** Ogni endpoint `/admin/*` confronta `X-Admin-Token` con
|
||||
`ADMIN_TOKEN` in tempo costante (`secrets.compare_digest` sui byte UTF-8,
|
||||
così un token con caratteri non ASCII dà 403 e non 500). Se `ADMIN_TOKEN` è
|
||||
vuoto **ogni** richiesta è rifiutata con 403, prima del confronto (che su due
|
||||
stringhe vuote darebbe esito positivo).
|
||||
vuoto **ogni** richiesta è rifiutata, prima del confronto (che su due
|
||||
stringhe vuote darebbe esito positivo). Il rifiuto è 403 con codice
|
||||
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
|
||||
non strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
|
||||
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
|
||||
@@ -311,14 +487,16 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
|
||||
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
|
||||
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
|
||||
| Cambio password per utente *(B-94, da implementare)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
|
||||
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
|
||||
|
||||
- **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori
|
||||
(username o IP) è in blocco, anche con la password giusta; la risposta è
|
||||
429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento
|
||||
alimenta entrambi i limitatori; un successo azzera solo quello dello
|
||||
username.
|
||||
- **[SEC-30]** <a id="sec-30"></a>Il login è bloccato se **uno qualunque**
|
||||
dei due limitatori (username o IP) è in blocco, anche con la password
|
||||
giusta; la risposta è 429 `rate_limited` con `retry_after_seconds` nei
|
||||
`params`. Ogni fallimento alimenta entrambi i limitatori; un successo
|
||||
azzera solo quello dello username.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter`,
|
||||
`app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
@@ -331,6 +509,37 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
|
||||
- **[SEC-54]** Il cambio password ha un freno sui fallimenti per utente, con
|
||||
le soglie della tabella: ogni `current_password` sbagliata registra un
|
||||
fallimento sulla chiave `user:<id utente>` di un `RateLimiter` dedicato
|
||||
(soglia 5, attesa iniziale 2s, massima 300s, lo stesso modello del login
|
||||
per username), un cambio riuscito lo azzera. Il blocco è verificato
|
||||
**prima** dell'hash Argon2 e anche con la password giusta; la risposta è
|
||||
429 `rate_limited` con `retry_after_seconds` nei `params`. Serve a chi ha
|
||||
rubato un token valido e vuole scoprire la password, per poi cambiarla e
|
||||
chiudere fuori il proprietario. L'evento `password_changed` e l'ordine
|
||||
degli errori sono di
|
||||
[REG-12](registrazione-e-depositi.md#sessione-e-password) e
|
||||
[API-21](api.md).
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido
|
||||
la password attuale si può provare senza limiti.
|
||||
|
||||
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
|
||||
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
|
||||
un `RollingQuota` dedicato), anche per un chiamante autenticato. Il
|
||||
controllo avviene prima dell'inserimento; un invio rifiutato per
|
||||
validazione non consuma la quota. Oltre la quota: 429 `rate_limited` con
|
||||
l'attesa esatta fino all'uscita dalla finestra della segnalazione più
|
||||
vecchia ([API-28](api.md)).
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può
|
||||
inserire segnalazioni senza limite, fino a riempire il database.
|
||||
|
||||
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
|
||||
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
|
||||
chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi
|
||||
@@ -340,25 +549,30 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
|
||||
- **[SEC-33]** Il canale SSE accetta al massimo 5 stream contemporanei per IP
|
||||
(il sesto chiude il più vecchio dello stesso IP invece di essere
|
||||
rifiutato) e 500 in totale (oltre, 503; il browser ripiega sul polling).
|
||||
rifiutato) e 500 in totale (oltre, 503 `too_many_streams`,
|
||||
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
|
||||
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/api/routes/rounds.py:round_stream` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94), per il codice `too_many_streams` ·
|
||||
*Oggi:* il 503 ha il corpo non strutturato
|
||||
`{"detail": "too many concurrent update streams"}`.
|
||||
|
||||
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, cambio
|
||||
password (tentativi sulla password attuale compresi), endpoint admin
|
||||
(tentativi sul token compresi), invio di bug report (anche anonimi), QR,
|
||||
`GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi
|
||||
sono serializzati per utente dal blocco per-utente (vedi
|
||||
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
|
||||
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
|
||||
gli altri endpoint di lettura. Scommesse e prelievi sono serializzati per
|
||||
utente dal blocco per-utente (vedi
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)),
|
||||
che non è un limite di frequenza.
|
||||
che non è un limite di frequenza ([LIM-07](decisioni-e-limiti.md)).
|
||||
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
|
||||
`app/api/routes/users.py:change_password`, `app/api/routes/bug_reports.py:create_bug_report`
|
||||
`app/api/routes/admin.py:require_admin`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e
|
||||
l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]).
|
||||
|
||||
## Indirizzo IP del client
|
||||
|
||||
Ogni controllo basato sull'IP (limitatori di login e registrazione, limite
|
||||
SSE per IP) usa la stessa funzione.
|
||||
Ogni controllo basato sull'IP (limitatori di login, registrazione e
|
||||
segnalazioni di bug, limite SSE per IP) usa la stessa funzione.
|
||||
|
||||
- **[SEC-35]** L'IP del client è l'**ultimo** elemento di `X-Forwarded-For`
|
||||
(quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il
|
||||
@@ -380,11 +594,90 @@ SSE per IP) usa la stessa funzione.
|
||||
l'ultimo hop di `X-Forwarded-For`.
|
||||
*Codice:* `docker-compose.yml`
|
||||
|
||||
## Quorum e TLS verso Electrum
|
||||
|
||||
Le regole sono di [connessione-electrum.md](connessione-electrum.md); qui il
|
||||
loro significato per la sicurezza.
|
||||
|
||||
- **Quorum minimo.** Il server rifiuta di avviarsi con meno di 2 server di
|
||||
riserva oltre al principale
|
||||
([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), e ogni
|
||||
corroborazione richiede almeno 2 risposte e la maggioranza stretta di chi
|
||||
risponde ([ELC-14](connessione-electrum.md#5-il-quorum)). Il server attivo
|
||||
non è mai interrogato come testimone di sé stesso. Con meno di due voci
|
||||
indipendenti, chi controlla il server attivo — o l'unico server di
|
||||
riserva raggiungibile — sceglierebbe da solo il blocco di estrazione, gli
|
||||
accrediti, le monete date per spese e le conferme. Una rete in cui
|
||||
rispondono meno di 2 server ritarda le decisioni, non le prende
|
||||
([DEC-07](decisioni-e-limiti.md)).
|
||||
- **`ALLOW_NO_QUORUM=true`** riporta la regola ridotta (nessun altro server:
|
||||
la verifica passa; altrimenti basta una risposta). Rende fidato il server
|
||||
attivo ed è **solo per lo sviluppo**; ogni avvio lo scrive nel log come
|
||||
avvertimento ([ELC-19](connessione-electrum.md#1-rotazione-tra-server)).
|
||||
- **TLS e pin del certificato.** Il certificato di un server Electrum non è
|
||||
verificato contro un'autorità, perché i server usano spesso certificati
|
||||
autofirmati. Può però essere **fissato**: `pin=<sha256>` su una voce di
|
||||
`ELECTRUM_FALLBACK_SERVERS` (`host:porta[:notls][:pin=<sha256>]`) ed
|
||||
`ELECTRUM_CERT_PIN` per il principale; un certificato diverso fa fallire
|
||||
la connessione come un server irraggiungibile
|
||||
([ELC-01](connessione-electrum.md#1-rotazione-tra-server),
|
||||
[ELC-03](connessione-electrum.md#1-rotazione-tra-server)). Senza pin, chi
|
||||
si mette in mezzo alla connessione verso un server ne prende il posto; il
|
||||
pin toglie questa possibilità, non quella di un server ostile in sé, contro
|
||||
cui resta il quorum. Una voce `notls` non ha né cifratura né pin, e conta
|
||||
nel quorum solo come server indipendente.
|
||||
- **Resta accettato** che i server del quorum siano scelti dall'operatore:
|
||||
più server controllati dalla stessa parte contano come voci indipendenti
|
||||
([LIM-11](decisioni-e-limiti.md)).
|
||||
|
||||
*Oggi* (B-84): un `ELECTRUM_FALLBACK_SERVERS` vuoto (il valore di
|
||||
`.env.example`) è accettato in silenzio e fa passare ogni corroborazione; con
|
||||
server di riserva la maggioranza è calcolata su chi risponde, quindi una sola
|
||||
risposta decide; nessun certificato è verificato e non esiste un modo di
|
||||
fissarlo.
|
||||
|
||||
## Host e catena di fornitura
|
||||
|
||||
Chi controlla l'host controlla tutti i fondi ([Modello di
|
||||
fiducia](#modello-di-fiducia)): queste regole non lo cambiano, ma riducono
|
||||
ciò che ottiene chi entra con meno privilegi (un altro utente dell'host, un
|
||||
processo nel container, una copia di `./data/`) o chi manomette ciò che
|
||||
entra nell'immagine.
|
||||
|
||||
- **[SEC-51]** <a id="sec-51"></a>Il processo del container `app` gira come
|
||||
un utente **non root** dedicato, dichiarato con `USER` nel `Dockerfile`.
|
||||
Sull'host `./data/`, le sue sottocartelle e `./secrets/` hanno permessi
|
||||
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
||||
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
||||
l'app crea (database, backup, log) nascono con permessi `0600`.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
|
||||
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
|
||||
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
|
||||
leggibili da ogni utente dell'host).
|
||||
|
||||
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
||||
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
||||
per digest (`@sha256:…`), e le dipendenze Python si installano da un file
|
||||
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
||||
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
||||
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono
|
||||
indicate per etichetta mobile e le dipendenze con `>=`: ogni build può
|
||||
installare versioni diverse, comprese versioni compromesse pubblicate dopo
|
||||
l'ultima build.
|
||||
|
||||
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
||||
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
||||
proprietario ([operativita.md](operativita.md#topologia)).
|
||||
|
||||
## TLS e header HTTP
|
||||
|
||||
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato ottiene e
|
||||
rinnova un certificato Let's Encrypt; con il valore predefinito
|
||||
`localhost` usa un certificato autofirmato della propria CA interna.
|
||||
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato a un dominio
|
||||
ottiene e rinnova un certificato Let's Encrypt; con `localhost` usa un
|
||||
certificato autofirmato della propria CA interna
|
||||
([ARC-01](architettura.md#panoramica-del-deployment)).
|
||||
*Codice:* `Caddyfile`, `docker-compose.yml`
|
||||
|
||||
- **[SEC-39]** Caddy aggiunge a ogni risposta questi header:
|
||||
@@ -408,10 +701,20 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
`style.css`/`admin.css`). I dati provenienti dal server inseriti con
|
||||
`innerHTML` nei due pannelli passano da una funzione `escapeHtml`.
|
||||
|
||||
- **[SEC-40]** Le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono
|
||||
servite con `Cache-Control: no-store`. Non è configurato alcun middleware
|
||||
CORS: il browser consente le chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`
|
||||
- **[SEC-40]** Il browser non usa mai una pagina o uno script di una
|
||||
versione precedente senza chiederne conferma al server: le pagine HTML
|
||||
(`/`, `/admin`, `/guida`, `/report-bug`) sono servite con
|
||||
`Cache-Control: no-store`, i file statici (JS, CSS) con
|
||||
`Cache-Control: no-cache` ([API-09](api.md#convenzioni-generali)), così
|
||||
una correzione di sicurezza nel frontend arriva al primo caricamento dopo
|
||||
il deploy. Non è configurato alcun middleware CORS: il browser consente le
|
||||
chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount di `app/static/` non hanno `Cache-Control`, solo
|
||||
`ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con
|
||||
una pagina nuova.
|
||||
|
||||
## Superficie esposta
|
||||
|
||||
@@ -423,8 +726,15 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
|
||||
- **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore
|
||||
standard (`internal_error`) e **senza** il testo dell'eccezione, che resta
|
||||
solo in `logs/app.log`.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`
|
||||
solo in `logs/app.log`. Anche ogni altra risposta d'errore — admin, SSE,
|
||||
validazione e framework compresi — ha il formato strutturato con un codice
|
||||
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
|
||||
così una password troppo lunga non torna indietro in chiaro.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
|
||||
*Stato:* da implementare (B-94), per gli errori diversi dal 500 ·
|
||||
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
|
||||
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
|
||||
`input` il valore ricevuto.
|
||||
|
||||
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
|
||||
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
@@ -435,10 +745,15 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
`fee_address`, QR) è accettato solo se inizia con `plm1` **e** supera la
|
||||
decodifica bech32 con checksum: un indirizzo valido di un'altra catena
|
||||
(`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero
|
||||
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato.
|
||||
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato, e
|
||||
così quello verso un indirizzo del montepremi, di riserva o delle
|
||||
commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo),
|
||||
B-80).
|
||||
*Codice:* `app/wallet/address.py:is_valid_plm_address`,
|
||||
`app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py`
|
||||
*Stato:* da implementare (B-80), per gli indirizzi di montepremi, riserva
|
||||
e commissioni · *Oggi:* è rifiutato solo il proprio indirizzo di deposito.
|
||||
|
||||
- **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un `<img>`, che non
|
||||
può portare il token) ma limitato: rifiuta con 400 `invalid_address` un
|
||||
@@ -450,25 +765,62 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
|
||||
- **[SEC-45]** Un bug report ha una descrizione obbligatoria di 1–2000
|
||||
caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256
|
||||
caratteri. Può essere inviato anche da non autenticati; se il mittente è
|
||||
autenticato viene associato al suo account, e solo lui vede i propri report
|
||||
(`GET /bug-reports/mine`, autenticato).
|
||||
caratteri. Può essere inviato anche da non autenticati, entro la quota di
|
||||
[SEC-55]; se il mittente è autenticato viene associato al suo account, e
|
||||
solo lui vede i propri report (`GET /bug-reports/mine`, autenticato).
|
||||
*Codice:* `app/api/routes/bug_reports.py:BugReportCreate`,
|
||||
`app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/api/routes/bug_reports.py:list_my_bug_reports` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
|
||||
- **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log,
|
||||
transazioni pendenti, bug report) accettano `limit` tra 1 e 500. L'elenco
|
||||
utenti non è paginato.
|
||||
transazioni pendenti, bug report, utenti) accettano `limit` tra 1 e 500;
|
||||
l'elenco utenti è paginato con `after_id`
|
||||
([API-33](api.md)).
|
||||
*Codice:* `app/api/routes/admin.py:list_rounds`, `app/api/routes/admin.py:list_audit_log`,
|
||||
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
|
||||
`app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:*
|
||||
`GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza
|
||||
limite né paginazione.
|
||||
|
||||
Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza
|
||||
SQL composto da stringhe.
|
||||
|
||||
## Ripristino e durabilità
|
||||
|
||||
Un database che torna indietro rispetto alla catena — per un backup
|
||||
ripristinato o per un commit perso — non è solo un problema di dati: può
|
||||
consegnare i fondi di un utente a un altro, o far pagare due volte un round.
|
||||
Le regole sono dei documenti proprietari; qui il loro significato per la
|
||||
sicurezza.
|
||||
|
||||
- **Indirizzi riassegnati.** Un database ripristinato non conosce gli utenti
|
||||
registrati dopo il backup e ne riassegnerebbe gli indici, e quindi
|
||||
indirizzi e fondi, ai nuovi iscritti. All'avvio si controllano i 20 indici
|
||||
successivi al più alto assegnato e, se uno ha cronologia in catena, la
|
||||
registrazione è rifiutata (`registration_unavailable`) e si scrive
|
||||
`restore_gap_detected`
|
||||
([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:*
|
||||
nessun controllo; l'unica difesa è la procedura
|
||||
[OPS-28](operativita.md#ripristino).
|
||||
- **Round rigiocati.** Con `START_PAUSED=true` il server parte con la
|
||||
lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round
|
||||
ha il proprio indirizzo del montepremi, le cui monete sono verificate con
|
||||
il quorum prima del pagamento
|
||||
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80, B-82): un round
|
||||
del backup già pagato risulta impagabile invece di essere pagato due volte
|
||||
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
|
||||
database e tutti i round condividono lo stesso indirizzo.
|
||||
- **Chiave sbagliata.** Una master key che non corrisponde al database
|
||||
ripristinato ferma l'avvio ([SEC-49]).
|
||||
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
|
||||
è sul disco ([DAT-52](modello-dati.md), B-88): un'interruzione di corrente
|
||||
non può più perdere una registrazione e far riassegnare il suo indice di
|
||||
derivazione. *Oggi:* `synchronous=NORMAL`, con cui in modalità WAL gli
|
||||
ultimi commit possono andare persi.
|
||||
|
||||
## Dati conservati e log
|
||||
|
||||
- **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash
|
||||
@@ -476,40 +828,77 @@ SQL composto da stringhe.
|
||||
`token_version`, data di creazione. **Nessuna email** né altro dato
|
||||
anagrafico. L'unico campo libero che può contenere un recapito è il
|
||||
`contact` facoltativo di un bug report. Gli indirizzi IP dei client non
|
||||
sono salvati nel database: l'app li usa solo nella memoria dei limitatori.
|
||||
sono salvati nel database: l'app li usa nella memoria dei limitatori e
|
||||
compaiono solo nel log di accesso ([SEC-48]).
|
||||
*Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport`
|
||||
|
||||
La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
|
||||
|
||||
- <a id="sec-56"></a>**[SEC-56]** **Il segreto del round è cifrato a riposo.**
|
||||
Fino alla rivelazione il database contiene il segreto dell'estrazione solo
|
||||
cifrato con Fernet, con la stessa chiave della master xprv
|
||||
([modello-dati.md](modello-dati.md) DAT-64). Chi ottiene una copia del
|
||||
database o di un backup, senza la chiave, non può calcolare in anticipo il
|
||||
vincitore di un round aperto, e quindi non può pilotarlo insieme a un
|
||||
minatore; per farlo servono database **e** chiave, cioè lo stesso livello
|
||||
di accesso che già consente di spendere i fondi ([LIM-25](decisioni-e-limiti.md)).
|
||||
Per questo la chiave va conservata separata dai backup del database
|
||||
([operativita.md](operativita.md) OPS-06). All'avvio la verifica di
|
||||
[SEC-49] decifra anche il segreto dei round non ancora rivelati: se non ci
|
||||
riesce, il server non parte.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* il segreto non esiste.
|
||||
- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password
|
||||
(nemmeno quella generata dal reset), né WIF, né token, né la master key. Il
|
||||
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file) contiene i
|
||||
messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn
|
||||
(indirizzo del peer diretto, metodo, percorso, esito: nessun percorso
|
||||
contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono
|
||||
(nemmeno quella generata dal reset), né WIF, né token, né la master key o
|
||||
la sua chiave di cifratura, né `server_seed` prima della rivelazione. Il
|
||||
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file, e lo stesso
|
||||
contenuto su stdout, quindi in `docker compose logs`) contiene i messaggi
|
||||
dell'app e di uvicorn, incluso il log di accesso di uvicorn (IP del client
|
||||
come lo riporta Caddy, metodo, percorso, esito: nessun percorso contiene un
|
||||
segreto, e gli header `Authorization`/`X-Admin-Token` non sono
|
||||
registrati). L'unico script che stampa un segreto è
|
||||
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
|
||||
generate dal reset compaiono solo nella risposta HTTP all'operatore.
|
||||
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
|
||||
`app/api/routes/admin.py:reset_user_password`
|
||||
*Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
|
||||
log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
|
||||
non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
|
||||
|
||||
## Rischi accettati
|
||||
|
||||
Limiti noti e voluti, descritti in dettaglio in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md):
|
||||
|
||||
- **Custodia totale.** Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i
|
||||
fondi; la cifratura a riposo non protegge da chi ha accesso all'host.
|
||||
- **Token admin unico e condiviso**, senza identità per operatore né limite
|
||||
di tentativi: una fuga ha il raggio d'azione massimo.
|
||||
- **JWT in `localStorage`**, quindi leggibile da un eventuale XSS; la CSP lo
|
||||
mitiga ma ammette script inline.
|
||||
- **Limiti di frequenza solo su login, registrazione e SSE**, in memoria e per
|
||||
singolo processo.
|
||||
- **Nessuna corroborazione se `ELECTRUM_FALLBACK_SERVERS` è vuoto**: il server
|
||||
Electrum principale diventa fidato (vedi
|
||||
[connessione-electrum.md](connessione-electrum.md#5-il-quorum)).
|
||||
- **Una sola conferma** per ogni transazione, con il rischio di
|
||||
riorganizzazione accettato e nessuna logica di rollback.
|
||||
- **Custodia totale** ([LIM-26](decisioni-e-limiti.md)). Chi controlla l'host
|
||||
o `ADMIN_TOKEN` controlla tutti i fondi; la cifratura a riposo e i permessi
|
||||
stretti non proteggono da chi ha accesso all'host.
|
||||
- **Operatore d'accordo con un minatore** ([LIM-25](decisioni-e-limiti.md)):
|
||||
possono scegliere il vincitore, perché l'operatore conosce il segreto del
|
||||
round prima della rivelazione.
|
||||
- **Attacco al 51% su PLM** ([LIM-24](decisioni-e-limiti.md)): contenuto
|
||||
(6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di
|
||||
ciò che è in catena), non impedito.
|
||||
- **Header a difficoltà minima** ([LIM-14](decisioni-e-limiti.md)): senza il
|
||||
ricalcolo della difficoltà, un server ostile può ancora produrre una breve
|
||||
catena falsa al powLimit; la neutralizzano il quorum e il commit-reveal.
|
||||
- **Server del quorum scelti dall'operatore** ([LIM-11](decisioni-e-limiti.md)):
|
||||
più server della stessa parte contano come voci indipendenti; con
|
||||
`ALLOW_NO_QUORUM=true` (solo sviluppo) il server attivo è fidato.
|
||||
- **Token admin unico e condiviso** ([LIM-06](decisioni-e-limiti.md)), senza
|
||||
identità per operatore né limite di tentativi: una fuga ha il raggio
|
||||
d'azione massimo.
|
||||
- **JWT in `localStorage`** ([LIM-09](decisioni-e-limiti.md)), quindi
|
||||
leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline.
|
||||
- **Limiti di frequenza parziali** ([LIM-07](decisioni-e-limiti.md)): login,
|
||||
registrazione, cambio password, segnalazioni di bug e SSE sì; scommesse,
|
||||
prelievi ed endpoint admin no. Tutti in memoria e per singolo processo.
|
||||
- **`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente**, visibili con
|
||||
`docker inspect` a chi ha accesso a Docker, che è già fidato come l'host.
|
||||
- **Una conferma per le transazioni della piattaforma**
|
||||
([DEC-06](decisioni-e-limiti.md)): scommesse, pagamenti e prelievi le
|
||||
firma solo il server, quindi un utente non può spenderne due volte gli
|
||||
ingressi; solo i depositi ne richiedono 6.
|
||||
- **La master key non è ruotabile**: gli indirizzi degli utenti sono
|
||||
permanenti.
|
||||
+51
-5
@@ -11,7 +11,7 @@ Questo file non contiene requisiti e quindi non ha identificativi propri.
|
||||
## Convenzione
|
||||
|
||||
- **Un difetto nuovo prende il prossimo numero libero**, senza riusare né
|
||||
rinumerare quelli esistenti. **Il prossimo numero libero è `B-73`.**
|
||||
rinumerare quelli esistenti. **Il prossimo numero libero è `B-99`.**
|
||||
- **Un commit dedicato per ogni difetto**, con il numero nel messaggio (ad
|
||||
esempio `Snapshot a round's timing when it opens (B-61)`), così
|
||||
`git log --all --grep 'B-nn'` ritrova sempre come è stato chiuso.
|
||||
@@ -41,9 +41,10 @@ repository, riscritto a ogni audit:
|
||||
| Audit 2026-07-27 | `43d2321`, `7224ca0` | B-25 … B-49 con gravità |
|
||||
| Chiusura | `0d2fef6` | file cancellato quando entrambe le liste erano vuote |
|
||||
| Audit 2026-08-03 | `99d7a1e` e successivi | B-52 … B-72, ogni voce rimossa al momento della correzione |
|
||||
| Revisione della specifica 2026-09-28 | `88221b0` e successivi | B-73 … B-98, aggiunte in coda al file |
|
||||
|
||||
Per leggere una versione: `git show <commit>:BUGS.md`. Il `BUGS.md` attuale,
|
||||
presente nel ramo `main`, contiene solo le voci ancora aperte (B-71 e B-72).
|
||||
presente nel ramo `main`, contiene solo le voci ancora aperte (B-71 … B-98).
|
||||
|
||||
La gravità è quella registrata in `BUGS.md` al momento dell'audit. Il terzo
|
||||
audit la definiva così, e la stessa scala vale in sostanza anche per i primi
|
||||
@@ -203,11 +204,56 @@ Note:
|
||||
insieme» di [scommesse-e-prelievi.md](scommesse-e-prelievi.md): quel titolo e
|
||||
le frasi che il test cerca vanno mantenuti.
|
||||
|
||||
## Revisione della specifica del 2026-09-28 (B-73 … B-98)
|
||||
|
||||
Quarta revisione, condotta sulla specifica in `docs/` invece che sul solo
|
||||
codice: quattro analisi indipendenti (meccanica della coin, macchina a stati
|
||||
del denaro, operatività, coerenza dei documenti), con le affermazioni critiche
|
||||
verificate nel codice. B-98 è emerso durante la stesura. Il comportamento
|
||||
atteso di ogni voce è già scritto nella specifica, nei requisiti marcati
|
||||
*da implementare*; le decisioni e l'ordine di lavoro sono in
|
||||
[piano-correzioni.md](piano-correzioni.md), il dettaglio tecnico in
|
||||
[BUGS.md](../BUGS.md).
|
||||
|
||||
La stessa revisione ha giudicato sottostimata la gravità di B-71: con la
|
||||
chiave duplicata, la procedura di rotazione della chiave di cifratura
|
||||
cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|
||||
|---|---|---|---|---|---|
|
||||
| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC |
|
||||
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
|
||||
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | **aperto** | — | ELC, TX |
|
||||
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | **aperto** | — | TX, BET, WDR |
|
||||
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | **aperto** | — | TX |
|
||||
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC |
|
||||
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX |
|
||||
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | **aperto** | — | RND, BET, WDR |
|
||||
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND |
|
||||
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
|
||||
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | **aperto** | — | ELC, TX, DEP |
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | **aperto** | — | ELC, SEC |
|
||||
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | **aperto** | — | ELC, RND |
|
||||
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | **aperto** | — | ELC, OPS |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
|
||||
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | **aperto** | — | DAT, ARC |
|
||||
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
|
||||
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
|
||||
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
|
||||
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
|
||||
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
|
||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
|
||||
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | **aperto** | — | RND, BET |
|
||||
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
|
||||
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | **aperto** | — | DAT |
|
||||
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | BET, WDR |
|
||||
|
||||
## Stato attuale
|
||||
|
||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato.
|
||||
- **Aperti:** B-71, B-72 (entrambi di gravità bassa, entrambi operativi o di
|
||||
documentazione).
|
||||
- **Prossimo numero libero:** `B-73`.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 (10 critici, 9 alti, 7 medi),
|
||||
da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
@@ -100,9 +100,9 @@ monete:
|
||||
|
||||
| Esito | Cosa significa | Cosa si fa |
|
||||
|---|---|---|
|
||||
| Accettato | Il server risponde con il txid, oppure rifiuta dicendo che la transazione è già nota | Si promuove la riga ([BET-17](scommesse-e-prelievi.md#cosa-succede), [WDR-12](scommesse-e-prelievi.md#prelievo)) |
|
||||
| Rifiutato esplicitamente | Il server risponde con un errore JSON-RPC diverso da "già nota" | Si annulla tutto ([TX-04]) |
|
||||
| Incerto | Timeout, connessione caduta, errore di scrittura sul socket: la transazione può essere partita | Non si libera nulla ([TX-20]) |
|
||||
| Accettato | Il server risponde con il txid, oppure rifiuta ma almeno un server conosce la transazione | Si promuove la riga ([BET-17](scommesse-e-prelievi.md#cosa-succede), [WDR-12](scommesse-e-prelievi.md#prelievo)) |
|
||||
| Rifiutato esplicitamente | Il server risponde con un errore JSON-RPC **e** nessun server interrogato conosce la transazione | Si annulla tutto ([TX-04]) |
|
||||
| Incerto | Timeout, connessione caduta, errore di scrittura sul socket, oppure un rifiuto la cui verifica non ottiene risposte: la transazione può essere partita | Non si libera nulla ([TX-20]) |
|
||||
|
||||
- **[TX-04]** Solo un **rifiuto esplicito** — una risposta del server con un
|
||||
oggetto `error` JSON-RPC — all'invio di una scommessa o di un prelievo
|
||||
@@ -111,12 +111,27 @@ monete:
|
||||
notifica SSE. Per una scommessa partecipante e riga vengono **cancellati**;
|
||||
per un prelievo prelievo e riga restano, in stato `failed`. L'utente riceve
|
||||
`broadcast_failed` con codice HTTP **502**, perché è stata la rete a
|
||||
rifiutare, non la sua richiesta a essere sbagliata. Un rifiuto che dice che
|
||||
la transazione è **già nota** (messaggi che contengono `already known`,
|
||||
`already in mempool`, `txn-already-known`, `txn-already-in-mempool` o
|
||||
`transaction already in block chain`) non è un rifiuto: vale come invio
|
||||
accettato.
|
||||
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal`, `app/electrum/client.py:ElectrumClient.broadcast` ·
|
||||
rifiutare, non la sua richiesta a essere sbagliata.
|
||||
|
||||
**Prima di annullare, il rifiuto si verifica.** Il testo dell'errore non
|
||||
basta a distinguere un rifiuto vero da un "già nota" (i server usano
|
||||
messaggi diversi e non esiste un codice standard), quindi dopo ogni rifiuto
|
||||
si chiede `blockchain.transaction.get` per il txid al server attivo e a
|
||||
tutti i server di riserva (10 s ciascuno):
|
||||
- se **almeno uno** restituisce la transazione, l'invio vale come
|
||||
**accettato** (la transazione è in rete);
|
||||
- se **tutti quelli che rispondono** dicono di non conoscerla, e rispondono
|
||||
almeno il server attivo e il numero minimo di server del quorum
|
||||
([connessione-electrum.md](connessione-electrum.md#5-il-quorum)), si
|
||||
annulla;
|
||||
- se le risposte non bastano, l'esito è **incerto** ([TX-20]).
|
||||
|
||||
Il riconoscimento dei messaggi "già nota" (`already known`,
|
||||
`already in mempool`, `txn-already-known`, `txn-already-in-mempool`,
|
||||
`transaction already in block chain`) resta solo una scorciatoia che salta
|
||||
la verifica: un messaggio non riconosciuto non può più far liberare le
|
||||
monete di una transazione partita, al massimo costa una verifica in più.
|
||||
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal`, `app/electrum/client.py:ElectrumClient.broadcast`, `app/electrum/client.py:ElectrumClient.get_transaction` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione dell'invio,
|
||||
compresi il timeout di 15 secondi, la connessione caduta dopo la scrittura
|
||||
@@ -301,6 +316,10 @@ sostituibilità fin dal primo invio.
|
||||
rete scrivono un evento `rbf_bump_failed` ([audit-log.md](audit-log.md)
|
||||
(B-87)) con la riga, il txid corrente e il motivo; la riga resta `pending`
|
||||
e il tentativo si ripete al giro successivo in cui è dovuto ([TX-09]).
|
||||
L'evento si scrive **una volta per coppia (txid corrente, motivo)**: un
|
||||
tentativo che fallisce di nuovo per lo stesso motivo sullo stesso txid va
|
||||
solo nei log, così una transazione non accelerabile non riempie il registro
|
||||
con una riga ogni 30 secondi.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore finisce solo nei log
|
||||
@@ -398,7 +417,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
Se uno di essi risulta confermato ([TX-22]) — una transazione data per
|
||||
morta che invece è arrivata in un blocco — si scrive una sola volta
|
||||
`pending_tx_resurrected` ([audit-log.md](audit-log.md) (B-78)) e la
|
||||
lotteria viene messa in pausa (`paused_reason = 'tx_resurrected'`) perché
|
||||
lotteria viene messa in pausa (`paused_reason = 'resurrected_tx'`) perché
|
||||
l'operatore rimetta in ordine partecipante, prelievo o pagamento; il
|
||||
riconciliatore non corregge da solo le righe collegate.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
|
||||
@@ -420,6 +439,19 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
mempool, il round resta in `closing`.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è
|
||||
**visibile**: quando un round resta in `closing` oltre la scadenza per più
|
||||
di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un
|
||||
evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round,
|
||||
il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s
|
||||
finché l'attesa dura; `GET /rounds/current` espone da quando il round attende
|
||||
(`closing_waiting_since`). L'operatore decide con la procedura di
|
||||
[operativita.md](operativita.md) (B-96); il sistema non esclude da solo una
|
||||
scommessa in volo dall'estrazione.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun
|
||||
segnale oltre al round che resta in `closing`.
|
||||
|
||||
## Pagamenti del round: riprovare senza pagare due volte
|
||||
|
||||
|
||||
Reference in new issue
Block a user