Document the 2026-09-28 specification review and its fixes (B-73..B-97)
Fourth full audit, run against the docs/ specification instead of the code alone, found logical flaws rooted in PLM's small hashrate (~22 TH/s): a minable draw, no distinction between "never broadcast" and "unknown if broadcast", an Electrum client that degrades as the pool address's history grows, and 1-confirmation deposits on a chain where a reorg is rentable. The spec now describes the target behaviour for every finding, marked *Stato:* da implementare (B-nn) with an *Oggi:* line for what the code does now; docs/piano-correzioni.md records the design decisions and the implementation order. No code changed — this is spec-only, per B-nn convention when a bug reveals the spec itself was incomplete. Also documents, as an accepted-but-open risk (LIM-24), that the optional jackpot cap (max_round_pool_sats) isn't tied to the cost of a 51% attack — deferred, not decided. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
61efbb0e37
commit
88221b0dbd
15 files changed
+4524
-1172
No files matched your search
+344
-106
@@ -34,6 +34,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
|
||||
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
|
||||
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
|
||||
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) — *da implementare (B-73)* |
|
||||
| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) |
|
||||
| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) |
|
||||
| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) |
|
||||
@@ -49,7 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
|
||||
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
|
||||
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
|
||||
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-86, B-92)* |
|
||||
| `GET` | `/health` | nessuna | [API-41](#get-health) |
|
||||
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
|
||||
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
|
||||
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
|
||||
|
||||
@@ -108,11 +111,15 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
illeggibile, scaduto o con firma errata, un utente non più esistente e un
|
||||
token *superato* (emesso prima dell'ultimo cambio o reset della password)
|
||||
rispondono tutti `401` con codice `session_expired`. L'**assenza** dell'header
|
||||
(o uno schema diverso da `Bearer`) è invece intercettata da FastAPI prima del
|
||||
codice applicativo: `401` con il corpo non strutturato
|
||||
`{"detail": "Not authenticated"}` e header `WWW-Authenticate: Bearer`.
|
||||
(o uno schema diverso da `Bearer`) risponde `401` con codice
|
||||
`not_authenticated`, nel formato strutturato di [API-12], con header
|
||||
`WWW-Authenticate: Bearer`. Il frontend tratta i due codici allo stesso
|
||||
modo: torna alla schermata di login.
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza
|
||||
dell'header è intercettata da FastAPI prima del codice applicativo e
|
||||
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
|
||||
|
||||
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
|
||||
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
|
||||
@@ -125,11 +132,13 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
- **[API-08]** Autenticazione **admin**: l'header `X-Admin-Token` è confrontato
|
||||
a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un
|
||||
`500`) con `ADMIN_TOKEN` del `.env`. Un `ADMIN_TOKEN` vuoto nega **tutto**,
|
||||
anche un header vuoto. Il rifiuto è `403` con il corpo non strutturato
|
||||
`{"detail": "invalid admin token"}`. Non c'è identità per singolo operatore
|
||||
anche un header vuoto. Il rifiuto è `403` con codice `invalid_admin_token`,
|
||||
nel formato strutturato di [API-12]. Non c'è identità per singolo operatore
|
||||
(vedi [sicurezza.md](sicurezza.md)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non
|
||||
strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[API-09]** Intestazioni di cache:
|
||||
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
|
||||
@@ -137,13 +146,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
- il flusso SSE risponde con `Cache-Control: no-store` e
|
||||
`X-Accel-Buffering: no`;
|
||||
- il QR risponde con `Cache-Control: private, max-age=86400, immutable`;
|
||||
- le risposte JSON e i file statici serviti dal mount (`/app.js`,
|
||||
`/style.css`, `/i18n.js`, `/index.html`…) **non** hanno `Cache-Control`:
|
||||
i file statici portano solo `ETag`/`Last-Modified` per la validazione
|
||||
condizionale.
|
||||
- ogni file servito dal mount dei file statici (`/app.js`, `/style.css`,
|
||||
`/i18n.js`, `/admin.js`, `/admin.css`, `/index.html`…) risponde con
|
||||
`Cache-Control: no-cache`, oltre a `ETag`/`Last-Modified`: il browser può
|
||||
tenerne una copia ma la rivalida a ogni uso, così dopo un aggiornamento
|
||||
del server non esegue un `app.js` vecchio contro l'API nuova;
|
||||
- le risposte JSON non hanno `Cache-Control`.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`,
|
||||
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`
|
||||
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`,
|
||||
`app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`,
|
||||
e il browser può riusarli senza rivalidarli.
|
||||
|
||||
- **[API-10]** La documentazione automatica di FastAPI (`/docs`, `/redoc`,
|
||||
`/openapi.json`) è **disattivata** a meno che `ENABLE_API_DOCS` non sia
|
||||
@@ -154,7 +169,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Test:* `tests/unit/test_docs_disabled.py`
|
||||
|
||||
- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti
|
||||
di login e registrazione, tetto per IP del flusso SSE), l'IP è l'**ultimo**
|
||||
di login e registrazione, tetto per IP del flusso SSE e, da implementare con
|
||||
B-94, quota per IP di `POST /bug-reports`), l'IP è l'**ultimo**
|
||||
elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua
|
||||
assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di
|
||||
Caddy sono in [sicurezza.md](sicurezza.md).
|
||||
@@ -191,9 +207,20 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
- **[API-13]** Gli errori di `POST /bets` e `POST /withdrawals` sollevati dal
|
||||
servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`:
|
||||
è la rete ad aver rifiutato la transazione, non il chiamante.
|
||||
`broadcast_failed` si usa **solo** per un rifiuto esplicito del server
|
||||
(un errore JSON-RPC in risposta al broadcast), e significa che tutto è stato
|
||||
annullato. Un esito **ambiguo** del broadcast (timeout, connessione caduta
|
||||
dopo l'invio) non è un errore: la richiesta risponde `202 Accepted` con lo
|
||||
stesso corpo del successo e `status: "building"`, perché la transazione può
|
||||
essere già sulla rete e la riga resta al riconciliatore
|
||||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76). Un rifiuto
|
||||
"già noto / già nel mempool" vale come successo (`201`).
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`,
|
||||
`app/api/routes/withdrawals.py:create_withdrawal` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast,
|
||||
compresi il timeout di 15 s e la caduta della connessione, annulla tutto e
|
||||
risponde `502 broadcast_failed`.
|
||||
|
||||
- **[API-14]** Qualunque eccezione non gestita risponde `500` con lo stesso
|
||||
formato strutturato, codice `internal_error`, messaggio
|
||||
@@ -204,36 +231,47 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Test:* `tests/unit/test_rounds_route.py`
|
||||
|
||||
- **[API-15]** Un corpo, un parametro di percorso o di query che non supera la
|
||||
validazione risponde `422` nel formato **nativo** di FastAPI: `detail` è una
|
||||
**lista** di oggetti `{type, loc, msg, input, ctx?}`, in inglese, uno per
|
||||
campo invalido (anche il JSON malformato arriva così, con
|
||||
`type: "json_invalid"`). Non c'è un `code`: il frontend riconosce la lista e
|
||||
mostra il messaggio generico tradotto `error.invalid_request`.
|
||||
*Codice:* FastAPI (nessun gestore personalizzato),
|
||||
validazione risponde `422` nel formato strutturato di [API-12], con codice
|
||||
`invalid_request`, `message` `"invalid request"` e in `params.errors` la
|
||||
lista degli errori di validazione, uno per campo invalido, ciascuno
|
||||
`{"loc", "msg", "type"}` in inglese (anche il JSON malformato arriva così,
|
||||
con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è
|
||||
ripetuto nella risposta, così una password troppo lunga non torna indietro
|
||||
in chiaro.
|
||||
*Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere),
|
||||
`app/static/app.js:apiErrorMessage` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`,
|
||||
`tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di
|
||||
FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`,
|
||||
senza `code`; il frontend riconosce la lista e mostra il messaggio generico
|
||||
tradotto `error.invalid_request`.
|
||||
|
||||
- **[API-16]** Alcune risposte d'errore **non** seguono il formato strutturato
|
||||
e hanno `detail` come semplice stringa inglese:
|
||||
- **[API-16]** **Nessuna** risposta d'errore ha `detail` come semplice
|
||||
stringa: anche gli errori generati dagli endpoint admin, dal flusso SSE e dal
|
||||
framework seguono il formato strutturato di [API-12], con questi codici:
|
||||
|
||||
| Stato | `detail` | Dove |
|
||||
|---|---|---|
|
||||
| `401` | `"Not authenticated"` | header `Authorization` assente su un endpoint Bearer (FastAPI) |
|
||||
| `403` | `"invalid admin token"` | ogni endpoint `/admin/*` |
|
||||
| `404` | `"user not found"` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` |
|
||||
| `404` | `"bug report not found"` | `POST /admin/bug-reports/{id}/status` |
|
||||
| `404` | `"Not Found"` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) |
|
||||
| `405` | `"Method Not Allowed"` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router |
|
||||
| `503` | `"too many concurrent update streams"` | `GET /rounds/stream` oltre il tetto globale |
|
||||
| Stato | Codice atteso | Dove | Oggi (`detail` stringa) |
|
||||
|---|---|---|---|
|
||||
| `401` | `not_authenticated` | header `Authorization` assente su un endpoint Bearer ([API-06]) | `"Not authenticated"` |
|
||||
| `403` | `invalid_admin_token` | ogni endpoint `/admin/*` ([API-08]) | `"invalid admin token"` |
|
||||
| `404` | `user_not_found` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` | `"user not found"` |
|
||||
| `404` | `bug_report_not_found` | `POST /admin/bug-reports/{id}/status` | `"bug report not found"` |
|
||||
| `404` | `not_found` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) | `"Not Found"` |
|
||||
| `405` | `method_not_allowed` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router | `"Method Not Allowed"` |
|
||||
| `503` | `too_many_streams` | `GET /rounds/stream` oltre il tetto globale | `"too many concurrent update streams"` |
|
||||
|
||||
Il frontend mostra la stringa così com'è.
|
||||
I `404`/`405` del framework si ottengono con un gestore di
|
||||
`StarletteHTTPException` che traduce lo stato nel codice della tabella.
|
||||
*Codice:* `app/api/routes/admin.py:require_admin`,
|
||||
`app/api/routes/admin.py:user_privkey`,
|
||||
`app/api/routes/admin.py:reset_user_password`,
|
||||
`app/api/routes/admin.py:update_bug_report_status`,
|
||||
`app/api/routes/rounds.py:round_stream` ·
|
||||
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella
|
||||
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
|
||||
mostra così com'è.
|
||||
|
||||
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
|
||||
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
|
||||
@@ -267,27 +305,37 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
usato, **senza distinzione tra maiuscole e minuscole**, anche se
|
||||
l'altro account si è registrato nello stesso istante);
|
||||
`409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile
|
||||
assegnare un indice di derivazione libero).
|
||||
assegnare un indice di derivazione libero); `503 registration_suspended`
|
||||
(all'avvio sono stati trovati movimenti sulla catena per indici di
|
||||
derivazione successivi all'ultimo assegnato, cioè il database potrebbe
|
||||
essere un backup più vecchio della realtà; la registrazione resta
|
||||
sospesa finché l'operatore non riconcilia,
|
||||
[operativita.md](operativita.md) B-90 — *da implementare*).
|
||||
- **Limite di frequenza:** al massimo 5 account creati per IP in una finestra
|
||||
mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e
|
||||
consumata solo da una registrazione riuscita (un nome già preso o un
|
||||
errore di validazione non la consumano). Dettagli in
|
||||
[sicurezza.md](sicurezza.md).
|
||||
- **Effetti:** inserisce l'utente con il nome così come digitato, il
|
||||
prossimo indice di derivazione e l'indirizzo derivato; avvia (senza
|
||||
attenderla, *best effort*) la sottoscrizione Electrum dell'indirizzo. Non
|
||||
scrive audit e non notifica l'SSE. Il flusso completo è in
|
||||
prossimo indice di derivazione e l'indirizzo derivato, e scrive
|
||||
`user_registered` con indice e indirizzo nella stessa transazione; avvia
|
||||
(senza attenderla, *best effort*) la sottoscrizione Electrum
|
||||
dell'indirizzo. Non notifica l'SSE. Il flusso completo è in
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md).
|
||||
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non
|
||||
esiste la sospensione `registration_suspended`.
|
||||
|
||||
#### POST /auth/login
|
||||
|
||||
- **[API-19]** Apre una sessione.
|
||||
- **Autenticazione:** nessuna.
|
||||
- **Corpo:** `{"username": stringa, "password": stringa}`, senza vincoli di
|
||||
lunghezza. Lo username è confrontato senza distinzione tra maiuscole e
|
||||
minuscole.
|
||||
- **Corpo:** `{"username": stringa, "password": stringa}`, ciascuno di al
|
||||
massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque
|
||||
hash Argon2 e senza consumare i contatori di fallimento). Lo username è
|
||||
confrontato senza distinzione tra maiuscole e minuscole.
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza.
|
||||
- **Risposta `200`:** `{"access_token": <JWT>, "address": <indirizzo>}`.
|
||||
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`,
|
||||
arrotondato per eccesso al secondo); `401 invalid_credentials` —
|
||||
@@ -332,20 +380,29 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
|
||||
- **[API-21]** Cambio password self-service.
|
||||
- **Autenticazione:** Bearer.
|
||||
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`.
|
||||
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`,
|
||||
ciascuno di al massimo 256 caratteri.
|
||||
- **Risposta `200`:** `{"access_token": <JWT>}` — un **nuovo** token per la
|
||||
sessione corrente.
|
||||
- **Errori**, nell'ordine in cui sono verificati: `401` di autenticazione;
|
||||
`401 current_password_incorrect`; `400 password_too_short`
|
||||
(`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi
|
||||
`400`, non `422` come alla registrazione) e **non** c'è una lunghezza
|
||||
massima.
|
||||
`422 invalid_request` (un campo oltre 256 caratteri); `429 rate_limited`
|
||||
(`params.retry_after_seconds`); `401 current_password_incorrect`;
|
||||
`400 password_too_short` (`params.minimum` = 8). La lunghezza minima è
|
||||
verificata nel gestore (quindi `400`, non `422` come alla registrazione).
|
||||
- **Limite di frequenza:** un contatore di fallimenti di
|
||||
`current_password` per utente, con attesa esponenziale come quello del
|
||||
login; il blocco è verificato prima dell'hash Argon2. Soglie in
|
||||
[sicurezza.md](sicurezza.md) (B-94).
|
||||
- **Effetti:** sostituisce l'hash e incrementa `User.token_version`: ogni
|
||||
token emesso prima, compreso quello usato per questa richiesta, da quel
|
||||
momento riceve `401 session_expired`. Non scrive audit e non notifica
|
||||
l'SSE. Nessun limite di frequenza.
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
momento riceve `401 session_expired`. Scrive `password_changed` nella
|
||||
stessa transazione. Non notifica l'SSE.
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun
|
||||
limite di frequenza (la password attuale si può provare senza limiti) e
|
||||
nessuna voce di audit.
|
||||
|
||||
#### GET /users/me/last-round-result
|
||||
|
||||
@@ -376,34 +433,46 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
|
||||
- **Autenticazione:** Bearer.
|
||||
- **Corpo:** nessuno.
|
||||
- **Risposta `201`:**
|
||||
- **Risposta `201`** (il server attivo ha accettato la transazione) o
|
||||
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
|
||||
|
||||
| Campo | Tipo | Significato |
|
||||
|---|---|---|
|
||||
| `round_id` | intero | round a cui la scommessa è iscritta |
|
||||
| `bet_txid` | stringa | txid della transazione trasmessa |
|
||||
| `bet_amount_sats` | intero | importo arrivato al montepremi, cioè il costo **meno** la commissione di rete |
|
||||
| `status` | stringa | stato del partecipante, `"broadcast"` |
|
||||
| `bet_txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) |
|
||||
| `bet_amount_sats` | intero | importo che arriva al montepremi, cioè il costo del round **meno** la commissione di rete |
|
||||
| `status` | stringa | stato del partecipante: `"broadcast"` con `201`, `"building"` con `202` |
|
||||
|
||||
- **Errori:** `503 network_unavailable` (nessuna connessione Electrum,
|
||||
verificato **prima** di ogni altro controllo); poi, tutti `400` salvo
|
||||
indicazione: `no_round_open`, `round_closing`, `already_betting`,
|
||||
`round_full` (`params.max_participants`), `insufficient_balance`
|
||||
(`params.required_sats`), `too_many_inputs` (`params.max_inputs`),
|
||||
`balance_leaves_no_change` (`params.required_extra_sats`),
|
||||
`amount_below_network_fee`, `amount_below_dust_limit`,
|
||||
`502 broadcast_failed`. Il significato di ciascuno è nel
|
||||
`round_full` (`params.max_participants`; anche quando il round ha
|
||||
raggiunto `max_round_pool_sats`, B-95), `balance_pending_confirmation`
|
||||
(`params.pending_sats`; il costo sarebbe coperto dal saldo comprensivo del
|
||||
resto non ancora confermato, ma non da quello confermato, B-96),
|
||||
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
|
||||
(`params.max_inputs`), `balance_leaves_no_change`
|
||||
(`params.required_extra_sats`), `amount_below_network_fee`,
|
||||
`amount_below_dust_limit`, `502 broadcast_failed` (solo rifiuto
|
||||
esplicito). Il significato di ciascuno è nel
|
||||
[catalogo](#catalogo-dei-codici-di-errore).
|
||||
- **Serializzazione:** la richiesta tiene il blocco per-utente condiviso con
|
||||
`POST /withdrawals` per tutta la costruzione e la trasmissione.
|
||||
- **Effetti:** può **aprire un round** se nessuno è attivo e le condizioni
|
||||
lo permettono; segna le monete spese, crea partecipante e transazione in
|
||||
sospeso, trasmette. Audit `bet_placed` in caso di successo,
|
||||
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in entrambi i casi
|
||||
(e all'apertura del round).
|
||||
lo permettono (lo stesso controllo dello scheduler, fatto dentro la
|
||||
richiesta); segna le monete spese, crea partecipante e transazione in
|
||||
sospeso, trasmette al server attivo e poi, *best effort*, a tutti i
|
||||
server di riserva. Audit `bet_placed` in caso di successo,
|
||||
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in tutti e tre i
|
||||
casi (successo, rifiuto, esito ambiguo) e all'apertura del round.
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`,
|
||||
`app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-76, B-95, B-96) · *Oggi:* ogni esito diverso
|
||||
dal successo è `502 broadcast_failed` con tutto annullato; la trasmissione
|
||||
va solo al server attivo; un costo coperto solo da resto non confermato
|
||||
risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è
|
||||
quello configurato al momento della scommessa, non quello del round.
|
||||
|
||||
#### POST /withdrawals
|
||||
|
||||
@@ -417,31 +486,43 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `external_address` | stringa | verificato nel servizio (bech32 valido con prefisso `plm1`) |
|
||||
| `amount_sats` | intero | nessun vincolo di schema; il minimo è verificato nel servizio |
|
||||
|
||||
- **Risposta `201`:**
|
||||
- **Risposta `201`** (il server attivo ha accettato la transazione) o
|
||||
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
|
||||
|
||||
| Campo | Tipo | Significato |
|
||||
|---|---|---|
|
||||
| `txid` | stringa | txid della transazione trasmessa |
|
||||
| `txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) |
|
||||
| `amount_requested_sats` | intero | importo chiesto |
|
||||
| `amount_sent_sats` | intero | importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto |
|
||||
| `status` | stringa | stato del prelievo, `"broadcast"` |
|
||||
| `status` | stringa | stato del prelievo: `"broadcast"` con `201`, `"building"` con `202` |
|
||||
|
||||
- **Errori:** `422`; `503 network_unavailable` (verificato **prima** della
|
||||
validazione dell'indirizzo); poi `400`: `invalid_address`,
|
||||
`withdrawal_to_own_address`, `amount_below_minimum`
|
||||
(`params.minimum_sats` = costo attuale della scommessa),
|
||||
`balance_pending_confirmation` (`params.pending_sats`),
|
||||
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
|
||||
(`params.max_inputs`), `amount_below_network_fee`,
|
||||
`amount_below_dust_limit`; `502 broadcast_failed`.
|
||||
validazione dell'indirizzo, quindi un indirizzo invalido riceve `503`
|
||||
finché la rete non torna); poi `400`: `invalid_address`,
|
||||
`withdrawal_to_own_address`, `withdrawal_to_platform_address`
|
||||
(destinazione uguale a un indirizzo di montepremi, di riserva o delle
|
||||
commissioni, B-80), `amount_below_minimum` (`params.minimum_sats` = costo
|
||||
attuale della scommessa; verificato anche sull'importo ridotto di un
|
||||
prelievo dell'intero saldo, B-96), `balance_pending_confirmation`
|
||||
(`params.pending_sats`), `insufficient_balance` (`params.required_sats`),
|
||||
`too_many_inputs` (`params.max_inputs`), `amount_below_network_fee`,
|
||||
`amount_below_dust_limit`; `502 broadcast_failed` (solo rifiuto
|
||||
esplicito).
|
||||
- **Serializzazione:** stesso blocco per-utente di `POST /bets`.
|
||||
- **Effetti:** segna le monete spese, crea prelievo e transazione in
|
||||
sospeso, trasmette. Audit `withdrawal_sent`, oppure
|
||||
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`;
|
||||
notifica SSE in entrambi i casi.
|
||||
sospeso, trasmette al server attivo e poi, *best effort*, a tutti i
|
||||
server di riserva. Audit `withdrawal_sent`, oppure
|
||||
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`
|
||||
per un rifiuto esplicito; con un esito ambiguo il prelievo resta
|
||||
`building`. Notifica SSE in tutti e tre i casi.
|
||||
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
|
||||
`app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-76, B-80, B-96) · *Oggi:* ogni esito diverso
|
||||
dal successo è `502 broadcast_failed` con il prelievo `failed`; la
|
||||
trasmissione va solo al server attivo; un prelievo verso l'indirizzo del
|
||||
pool o delle commissioni è accettato; l'importo ridotto di un prelievo
|
||||
dell'intero saldo non è riconfrontato con il minimo.
|
||||
|
||||
### Round
|
||||
|
||||
@@ -461,7 +542,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `opened_at` | stringa ISO o `null` | apertura del round |
|
||||
| `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) |
|
||||
| `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae |
|
||||
| `bet_amount_sats` | intero | costo della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) |
|
||||
| `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato — *da implementare (B-95); oggi sempre quello configurato* |
|
||||
| `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre |
|
||||
| `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) |
|
||||
| `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo |
|
||||
@@ -470,17 +551,27 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `winner_user_id` | intero o `null` | vincitore, valorizzato dall'estrazione in poi (`paying_out`) |
|
||||
| `winner_amount_sats` | intero o `null` | importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita |
|
||||
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
|
||||
| `draw_block_hash` | stringa o `null` | hash di quel blocco (il seme dell'estrazione) |
|
||||
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
|
||||
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
|
||||
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 — *da implementare (B-73)* |
|
||||
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` — *da implementare (B-73)* |
|
||||
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` — *da implementare (B-73)* |
|
||||
| `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round — *da implementare (B-73, B-95)* |
|
||||
| `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore — *da implementare (B-80)* |
|
||||
| `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota |
|
||||
| `lottery_paused` | booleano | pausa di manutenzione attiva |
|
||||
| `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo |
|
||||
| `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) — *da implementare (B-78, B-82, B-85)* |
|
||||
| `lottery_configured` | booleano | falso finché manca configurazione indispensabile (oggi: `fee_address`), e quindi nessun round può aprirsi |
|
||||
| `user_played` | booleano | vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo |
|
||||
|
||||
Il segreto del round (`server_seed`) **non** compare mai in questa
|
||||
risposta: si legge solo da [API-45](#get-roundsround_idproof), dopo che il
|
||||
vincitore è stato scritto.
|
||||
|
||||
Senza round attivo sono valorizzati solo `server_time`, `bet_amount_sats`,
|
||||
`draw_animation_seconds`, `chain_tip_height`, `lottery_paused` e
|
||||
`lottery_configured`; gli altri valgono `null`, `0` o `false`. Il
|
||||
significato di dominio di ogni fase è in
|
||||
`draw_animation_seconds`, `chain_tip_height`, `lottery_paused`,
|
||||
`paused_reason` e `lottery_configured`; gli altri valgono `null`, `0` o
|
||||
`false`. Il significato di dominio di ogni fase è in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
- **Errori:** nessuno previsto (un token invalido non è un errore,
|
||||
[API-07](#convenzioni-generali)).
|
||||
@@ -491,6 +582,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
|
||||
`app/rounds/service.py:rounds_can_open` ·
|
||||
*Test:* `tests/unit/test_rounds_route.py`
|
||||
*Stato:* da implementare (B-73, B-78, B-80, B-82, B-85, B-95), per i campi
|
||||
marcati · *Oggi:* quei campi non esistono, `bet_amount_sats` è sempre il
|
||||
costo configurato, e `draw_block_hash` è il seme dell'estrazione.
|
||||
|
||||
#### GET /rounds/stream
|
||||
|
||||
@@ -509,14 +603,88 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
- **Chiusura:** la connessione di un client scomparso senza chiusura pulita
|
||||
è rilevata entro 5 secondi. Oltre **5** flussi contemporanei dallo stesso
|
||||
IP, l'apertura di un nuovo flusso **chiude il più vecchio** di quell'IP.
|
||||
- **Errori:** `503` con `{"detail": "too many concurrent update streams"}`
|
||||
(non strutturato) quando i flussi aperti in totale sono già **500**; il
|
||||
client `EventSource` riprova da solo e intanto resta il polling.
|
||||
- **Errori:** `503 too_many_streams` (formato strutturato,
|
||||
[API-16](#contratto-degli-errori)) quando i flussi aperti in totale sono
|
||||
già **500**; il client `EventSource` riprova da solo e intanto resta il
|
||||
polling.
|
||||
- **Chiusura all'arresto del server:** allo spegnimento ogni flusso aperto
|
||||
viene chiuso dal server, così l'arresto non attende i client
|
||||
([architettura.md](architettura.md) B-93).
|
||||
- **Effetti:** nessuno.
|
||||
- **Cosa non notifica:** `PUT /admin/config`, `POST /admin/pause` e
|
||||
`POST /admin/resume` non pubblicano sul canale; `/` vede
|
||||
`lottery_paused`/`paused_reason` e la nuova configurazione al polling
|
||||
successivo di `GET /rounds/current`.
|
||||
*Codice:* `app/api/routes/rounds.py:round_stream`,
|
||||
`app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/rounds/events.py:RoundEventBroadcaster.publish` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non
|
||||
strutturato `{"detail": "too many concurrent update streams"}`, e un flusso
|
||||
aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non
|
||||
termina il processo.
|
||||
|
||||
#### GET /rounds/{round_id}/proof
|
||||
|
||||
- **[API-45]** La prova pubblica dell'estrazione di un round: tutto ciò che
|
||||
serve a chiunque per ricalcolare il vincitore senza fidarsi del server.
|
||||
L'algoritmo e la codifica esatta dei byte sono definiti in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); qui è
|
||||
specificata la forma della risposta.
|
||||
- **Autenticazione:** nessuna.
|
||||
- **Parametro di percorso:** `round_id` intero (non intero: `422`).
|
||||
- **Risposta `200`**, per un round in qualunque stato; i campi non ancora
|
||||
determinati valgono `null` (o lista vuota):
|
||||
|
||||
| Campo | Tipo | Valorizzato da | Significato |
|
||||
|---|---|---|---|
|
||||
| `round_id` | intero | sempre | — |
|
||||
| `status` | stringa | sempre | stato del round |
|
||||
| `algorithm` | stringa | sempre | `"commit-reveal-v1"` per i round aperti con B-73; `"block-hash-v0"` per quelli precedenti, per cui la prova è incompleta (vedi sotto) |
|
||||
| `pool_address` | stringa | sempre | indirizzo del montepremi del round |
|
||||
| `server_seed_hash` | stringa | apertura | `sha256(server_seed)`, in esadecimale |
|
||||
| `server_seed` | stringa o `null` | **solo dopo** che il vincitore è stato scritto (`paying_out` e `closed`) | il segreto del round, 32 byte in esadecimale |
|
||||
| `draw_height` | intero o `null` | ingresso in `drawing` | altezza del blocco di estrazione |
|
||||
| `draw_confirmations` | intero | apertura | conferme richieste su quel blocco prima di leggerne l'hash |
|
||||
| `participants` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita) e `status` `confirmed` o `reorged_out` |
|
||||
| `participants_hash` | stringa o `null` | ingresso in `drawing` | impronta dell'elenco `participants` |
|
||||
| `draw_block_hash` | stringa o `null` | estrazione | hash del blocco a `draw_height` |
|
||||
| `seed` | stringa o `null` | estrazione | il seme in decimale (`rounds.seed_int`) |
|
||||
| `winner_index` | intero o `null` | estrazione | `seed mod` numero di elementi di `participants` |
|
||||
| `winner_address` | stringa o `null` | estrazione | `participants[winner_index].address` |
|
||||
|
||||
- **Come si verifica** (round `commit-reveal-v1`):
|
||||
1. prima della chiusura, annotare `server_seed_hash` da `GET /rounds/current`
|
||||
o da questo endpoint;
|
||||
2. dopo la rivelazione, controllare che `sha256(server_seed)` sia uguale a
|
||||
quel valore: il segreto non è stato cambiato dopo l'apertura;
|
||||
3. ricalcolare `participants_hash` da `participants`, e controllare su un
|
||||
esploratore o un nodo che ogni `txid:vout` sia un output confermato
|
||||
verso `pool_address`;
|
||||
4. controllare su un esploratore o un nodo che il blocco a `draw_height`
|
||||
abbia hash `draw_block_hash` e che la catena abbia almeno
|
||||
`draw_confirmations` blocchi da lì;
|
||||
5. ricalcolare `seed` da `server_seed`, `draw_block_hash` e
|
||||
`participants_hash`, poi `winner_index = seed mod n` con `n` il numero
|
||||
di elementi di `participants`, e confrontare `winner_address`.
|
||||
|
||||
Per un round `block-hash-v0` la risposta ha `server_seed_hash`,
|
||||
`server_seed`, `draw_height` e `participants_hash` a `null` e
|
||||
`participants` vuota, perché quei round non hanno registrato né un
|
||||
impegno né l'ordine dei partecipanti: `seed` e `draw_block_hash` si
|
||||
possono confrontare, il vincitore no.
|
||||
- **Riservatezza:** `server_seed` non compare in nessuna risposta, di
|
||||
nessun endpoint (admin compresi), finché `rounds.winner_user_id` è `NULL`
|
||||
([modello-dati.md](modello-dati.md), DAT-64). Gli indirizzi dei
|
||||
partecipanti sono già pubblici sulla catena: le scommesse li collegano a
|
||||
`pool_address`.
|
||||
- **Errori:** `404 round_not_found`; `422`.
|
||||
- **Effetti:** nessuno.
|
||||
*Codice:* `app/api/routes/rounds.py` (endpoint da scrivere),
|
||||
`app/rounds/draw.py:draw_winner` · *Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* l'endpoint non esiste; l'unico
|
||||
dato pubblico dell'estrazione è `draw_block_hash` in `GET /rounds/current`,
|
||||
finché il round è attivo.
|
||||
|
||||
### QR
|
||||
|
||||
@@ -553,12 +721,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `contact` | stringa o `null` | facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa `null` |
|
||||
|
||||
- **Risposta `201`:** `{"id": intero}`.
|
||||
- **Errori:** `422`.
|
||||
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`).
|
||||
- **Limite di frequenza:** una quota di segnalazioni per IP
|
||||
([API-11](#convenzioni-generali)) in una finestra mobile, verificata prima
|
||||
dell'inserimento e consumata solo da un inserimento riuscito, anche per un
|
||||
chiamante autenticato; soglie in [sicurezza.md](sicurezza.md) (B-94).
|
||||
- **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit,
|
||||
nessuna notifica SSE, nessun limite di frequenza.
|
||||
nessuna notifica SSE.
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/api/routes/bug_reports.py:BugReportCreate` ·
|
||||
`app/api/routes/bug_reports.py:BugReportCreate`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un
|
||||
anonimo può inserire segnalazioni senza limite.
|
||||
|
||||
#### GET /bug-reports/mine
|
||||
|
||||
@@ -575,7 +750,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
### Amministrazione
|
||||
|
||||
Tutti gli endpoint `/admin/*` richiedono `X-Admin-Token`
|
||||
([API-08](#convenzioni-generali)) e rispondono `403` senza di esso. Gli eventi
|
||||
([API-08](#convenzioni-generali)) e rispondono `403 invalid_admin_token`
|
||||
senza di esso. Nessuno restituisce `rounds.server_seed` prima che il
|
||||
vincitore del round sia stato scritto ([API-45](#get-roundsround_idproof)). Gli eventi
|
||||
di audit citati sono descritti in [audit-log.md](audit-log.md); il significato
|
||||
dei parametri operativi in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
|
||||
@@ -587,9 +764,13 @@ dei parametri operativi in
|
||||
`round_config`, creata con i valori predefiniti se manca).
|
||||
- **Risposta `200`:** `{"fee_address", "bet_amount_sats",
|
||||
"round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds", "draw_animation_seconds", "paused"}`.
|
||||
"rbf_timeout_seconds", "draw_animation_seconds", "deposit_confirmations",
|
||||
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
|
||||
*Codice:* `app/api/routes/admin.py:read_config` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:* mancano
|
||||
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e
|
||||
`paused_reason`.
|
||||
|
||||
#### PUT /admin/config
|
||||
|
||||
@@ -606,41 +787,73 @@ dei parametri operativi in
|
||||
| `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) |
|
||||
| `rbf_timeout_seconds` | 60 – 604 800 |
|
||||
| `draw_animation_seconds` | 0 – 600 |
|
||||
| `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* |
|
||||
| `draw_confirmations` | 1 – 20 — *da implementare (B-73)* |
|
||||
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
|
||||
|
||||
`paused` **non** è accettato qui (è ignorato come campo sconosciuto): la
|
||||
I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella
|
||||
`round_config`), che ne è il proprietario. `paused` e `paused_reason`
|
||||
**non** sono accettati qui (sono ignorati come campi sconosciuti): la
|
||||
pausa ha i suoi endpoint registrati nell'audit.
|
||||
- **Risposta `200`:** la configurazione completa, come
|
||||
[API-30](#get-adminconfig).
|
||||
- **Errori:** `422` per qualunque vincolo violato (nessun campo viene
|
||||
applicato).
|
||||
- **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli
|
||||
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata e pausa
|
||||
tra round si applicano dal round successivo. Nessuna notifica SSE.
|
||||
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa
|
||||
tra round, costo della scommessa, `fee_address` e `draw_confirmations` si
|
||||
applicano dal round successivo (per costo e `fee_address`: *da
|
||||
implementare*, B-95; oggi subito). Nessuna notifica SSE.
|
||||
*Codice:* `app/api/routes/admin.py:update_config`,
|
||||
`app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-73, B-82, B-95), per i tre campi nuovi e la
|
||||
copia all'apertura · *Oggi:* sono accettati solo i primi sette campi.
|
||||
|
||||
#### POST /admin/pause e POST /admin/resume
|
||||
|
||||
- **[API-32]** Attivano e disattivano la pausa di manutenzione: impedisce
|
||||
l'apertura del **prossimo** round, il round in corso prosegue fino al
|
||||
pagamento.
|
||||
- **Corpo:** nessuno. **Risposta `200`:** la configurazione completa.
|
||||
- **Effetti:** impostano `paused` a vero/falso e scrivono `lottery_paused` /
|
||||
`lottery_resumed` a **ogni** chiamata, anche se lo stato era già quello.
|
||||
Nessuna notifica SSE: `/` vede `lottery_paused` al polling successivo.
|
||||
- **[API-32]** Attivano e disattivano la pausa: impedisce l'apertura del
|
||||
**prossimo** round, il round in corso prosegue fino al pagamento (salvo un
|
||||
pagamento trattenuto, vedi sotto). La semantica di `paused_reason` è in
|
||||
[modello-dati.md](modello-dati.md) (DAT-69).
|
||||
- **`POST /admin/pause` — corpo:** facoltativo, `{"note": stringa}` di al
|
||||
massimo 256 caratteri, un promemoria libero dell'operatore che finisce solo
|
||||
nel payload di `lottery_paused`. Imposta `paused = true` e
|
||||
`paused_reason = 'operator'`, tranne quando è già attivo un motivo
|
||||
automatico (`reorg`, `payout_blocked`, `resurrected_tx`), che resta.
|
||||
- **`POST /admin/resume` — corpo:** nessuno. È l'unico modo di uscire da
|
||||
**qualunque** pausa, anche automatica: imposta `paused = false`,
|
||||
`paused_reason = NULL` e azzera `rounds.payout_blocked_at` del round
|
||||
attivo, se valorizzato (il pagamento viene poi riverificato,
|
||||
DAT-68). Il payload di `lottery_resumed` riporta il `paused_reason` che è
|
||||
stato tolto, così il registro dice che cosa l'operatore ha accettato di
|
||||
riprendere.
|
||||
- **Risposta `200`** (entrambi): la configurazione completa, come
|
||||
[API-30](#get-adminconfig), compreso `paused_reason`.
|
||||
- **Errori:** `422` (nota oltre 256 caratteri).
|
||||
- **Effetti:** scrivono `lottery_paused` / `lottery_resumed` a **ogni**
|
||||
chiamata, anche se lo stato era già quello. Nessuna notifica SSE: `/`
|
||||
vede `lottery_paused` al polling successivo.
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery`,
|
||||
`app/api/routes/admin.py:resume_lottery` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* nessun corpo, nessun
|
||||
`paused_reason`, e la pausa è solo manuale.
|
||||
|
||||
#### GET /admin/users
|
||||
|
||||
- **[API-33]** Elenco di **tutti** gli utenti, per `id` crescente, senza
|
||||
paginazione né limite.
|
||||
- **[API-33]** Elenco degli utenti, per `id` crescente, a pagine.
|
||||
- **Query:** `limit` intero 1–500, predefinito 100; `after_id` intero
|
||||
facoltativo: restituisce solo gli utenti con `id` maggiore. La pagina
|
||||
successiva si chiede con `after_id` uguale all'`id` dell'ultimo elemento
|
||||
ricevuto; una lista più corta di `limit` è l'ultima pagina. Fuori
|
||||
intervallo: `422`.
|
||||
- **Risposta `200`:** lista di `{"id", "username", "address",
|
||||
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
|
||||
*Codice:* `app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti
|
||||
in una sola risposta, senza paginazione né limite.
|
||||
|
||||
#### GET /admin/users/{user_id}/privkey
|
||||
|
||||
@@ -649,8 +862,9 @@ dei parametri operativi in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
- **Parametro:** `user_id` intero.
|
||||
- **Risposta `200`:** `{"address", "wif"}`.
|
||||
- **Errori:** `404` `"user not found"` (non strutturato); `422` se `user_id`
|
||||
non è un intero.
|
||||
- **Errori:** `404 user_not_found` (oggi non strutturato, `"user not
|
||||
found"`, [API-16](#contratto-degli-errori)); `422` se `user_id` non è un
|
||||
intero.
|
||||
- **Effetti:** scrive `admin_privkey_accessed` a ogni accesso.
|
||||
*Codice:* `app/api/routes/admin.py:user_privkey` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
@@ -660,7 +874,8 @@ dei parametri operativi in
|
||||
- **[API-35]** Imposta una nuova password casuale per un utente bloccato.
|
||||
- **Risposta `200`:** `{"username", "new_password"}` — la password (16
|
||||
caratteri URL-safe) è mostrata **solo** in questa risposta.
|
||||
- **Errori:** `404` `"user not found"`; `422`.
|
||||
- **Errori:** `404 user_not_found` (oggi non strutturato,
|
||||
[API-16](#contratto-degli-errori)); `422`.
|
||||
- **Effetti:** sostituisce l'hash, incrementa `token_version` (ogni sessione
|
||||
aperta dell'utente riceve `401 session_expired`), scrive
|
||||
`admin_password_reset`.
|
||||
@@ -674,19 +889,40 @@ dei parametri operativi in
|
||||
- **Risposta `200`:** lista di `{"id", "status", "opened_at", "closed_at",
|
||||
"draw_block_height", "draw_block_hash", "winner_user_id",
|
||||
"winner_username", "pool_amount_sats", "winner_amount_sats",
|
||||
"fee_amount_sats", "payout_txid"}`; i campi non ancora valorizzati sono
|
||||
`null`.
|
||||
"fee_amount_sats", "payout_txid", "pool_address", "server_seed_hash",
|
||||
"draw_height", "bump_reserve_sats", "payout_blocked_at"}`; i campi non
|
||||
ancora valorizzati sono `null`. `server_seed` non è tra i campi: la prova
|
||||
completa di un round è in [API-45](#get-roundsround_idproof).
|
||||
*Codice:* `app/api/routes/admin.py:list_rounds` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-73, B-79, B-80, B-82), per gli ultimi cinque
|
||||
campi · *Oggi:* la lista si ferma a `payout_txid`.
|
||||
|
||||
#### GET /admin/audit-log
|
||||
|
||||
- **[API-37]** Registro di audit, dalla voce più recente.
|
||||
- **Query:** `limit` intero 1–500, predefinito 200.
|
||||
- **[API-37]** Registro di audit, dalla voce più recente, filtrabile e a
|
||||
pagine.
|
||||
- **Query**, tutti facoltativi e combinati in **and**:
|
||||
|
||||
| Parametro | Tipo | Filtro |
|
||||
|---|---|---|
|
||||
| `limit` | intero 1–500, predefinito 200 | numero massimo di voci |
|
||||
| `before_id` | intero | solo voci con `id` minore: la pagina successiva si chiede con l'`id` dell'ultima voce ricevuta; una lista più corta di `limit` è l'ultima pagina |
|
||||
| `event_type` | stringa, ripetibile | solo voci di uno dei tipi indicati, confrontati alla lettera (un tipo inesistente dà una lista vuota) |
|
||||
| `round_id` | intero | solo voci con quel `round_id` |
|
||||
| `user_id` | intero | solo voci con quel `user_id` |
|
||||
| `from` | stringa ISO 8601 con offset | solo voci con `created_at` ≥ questo istante |
|
||||
| `to` | stringa ISO 8601 con offset | solo voci con `created_at` < questo istante |
|
||||
|
||||
Un valore non interpretabile (un intero non valido, un istante senza
|
||||
offset) risponde `422 invalid_request`.
|
||||
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
|
||||
"user_id", "round_id", "created_at"}`.
|
||||
"user_id", "round_id", "created_at"}`, per `id` decrescente.
|
||||
*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`, quindi oltre le ultime 500 voci il registro non è
|
||||
consultabile dall'API.
|
||||
|
||||
#### GET /admin/pending-transactions
|
||||
|
||||
@@ -721,7 +957,9 @@ dei parametri operativi in
|
||||
transizione è ammessa, anche all'indietro.
|
||||
- **Risposta `200`:** la segnalazione aggiornata, come
|
||||
[API-39](#get-adminbug-reports).
|
||||
- **Errori:** `422` (stato sconosciuto); `404` `"bug report not found"`.
|
||||
- **Errori:** `422` (stato sconosciuto); `404 bug_report_not_found` (oggi
|
||||
non strutturato, `"bug report not found"`,
|
||||
[API-16](#contratto-degli-errori)).
|
||||
- **Effetti:** scrive `bug_report_status_changed` (con stato precedente e
|
||||
nuovo) solo se lo stato cambia davvero.
|
||||
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
|
||||
|
||||
+271
-67
@@ -19,29 +19,45 @@ flowchart LR
|
||||
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
|
||||
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
|
||||
A -- "legge" --> K["./data/keys/master.xprv.enc"]
|
||||
A -- "scrive" --> L["./data/logs/app.log"]
|
||||
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
|
||||
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
|
||||
```
|
||||
|
||||
- **[ARC-01]** Il server gira sempre in Docker, con lo stesso
|
||||
`docker-compose.yml` in sviluppo e in produzione: due container, `app`
|
||||
(questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo
|
||||
`SITE_ADDRESS`: non impostato vale `localhost` (certificato autofirmato
|
||||
dalla CA interna di Caddy), impostato a un dominio produce un certificato
|
||||
Let's Encrypt. Non esiste un modo supportato di lanciare `uvicorn` fuori da
|
||||
Docker.
|
||||
`SITE_ADDRESS`, che sta in `.env` ed è obbligatorio: il compose lo legge
|
||||
come `${SITE_ADDRESS:?}` e si rifiuta di partire se manca. `localhost`
|
||||
produce un certificato autofirmato dalla CA interna di Caddy, un dominio un
|
||||
certificato Let's Encrypt. Non esiste un modo supportato di lanciare
|
||||
`uvicorn` fuori da Docker.
|
||||
*Codice:* `docker-compose.yml` · `Caddyfile` ·
|
||||
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
|
||||
*Stato:* da implementare (B-93) · *Oggi:* `SITE_ADDRESS` si passa sul
|
||||
comando e non impostato vale `localhost`, quindi un `docker compose up`
|
||||
senza variabile riporta un server di produzione su `localhost`.
|
||||
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
|
||||
`--workers`), in ascolto su `0.0.0.0:8123`. Tutto lo stato in memoria
|
||||
descritto in [Assunzioni di processo singolo](#assunzioni-di-processo-singolo)
|
||||
dipende da questo.
|
||||
*Codice:* `Dockerfile` (`CMD`)
|
||||
`--workers`), in ascolto su `0.0.0.0:8123`, con `--proxy-headers` e
|
||||
`--forwarded-allow-ips` limitato all'indirizzo di Caddy: la rete di compose
|
||||
ha una sottorete fissa e Caddy un indirizzo IPv4 fisso in essa. Così il log
|
||||
di accesso registra l'IP del client invece di quello di Caddy. Tutto lo
|
||||
stato in memoria descritto in
|
||||
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
|
||||
questo.
|
||||
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
|
||||
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
|
||||
proxy e il log di accesso registra l'IP di Caddy.
|
||||
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
|
||||
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
|
||||
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
|
||||
`exec`. Le migrazioni non si lanciano mai a mano.
|
||||
`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
|
||||
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,
|
||||
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
|
||||
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
|
||||
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
|
||||
443. Su questo si basa la lettura dell'IP del client (vedi
|
||||
@@ -54,8 +70,34 @@ flowchart LR
|
||||
`./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e
|
||||
`MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili
|
||||
arrivano da `.env` (`env_file`). Entrambi i container hanno
|
||||
`restart: unless-stopped`.
|
||||
`restart: unless-stopped`. La chiave di cifratura della master key è un
|
||||
file a parte, `./secrets/xprv_encryption.key` (permessi `0600`, fuori da
|
||||
`./data/` così un backup dei dati non la contiene), montato in sola lettura
|
||||
come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da
|
||||
`XPRV_ENCRYPTION_KEY_FILE` ([ARC-30]). Le directory di `./data/` hanno
|
||||
permessi `0700`, i file di chiave `0600`. Le immagini sono etichettate per
|
||||
rilascio, così un rollback sa quale immagine rimettere
|
||||
([operativita.md](operativita.md) (B-93)).
|
||||
*Codice:* `docker-compose.yml`
|
||||
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
|
||||
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
|
||||
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
|
||||
|
||||
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
||||
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
|
||||
(timeout 5s, 3 tentativi, `start_period` 60s). `/health/ready` risponde 200
|
||||
solo se la connessione Electrum è attiva, l'ultimo header è recente e lo
|
||||
scheduler ha battuto da poco; le soglie sono di
|
||||
[api.md](api.md#servizio) (B-92). Lo scheduler aggiorna il battito a ogni
|
||||
giro **e** a ogni iterazione dell'attesa del blocco di estrazione, perché
|
||||
quell'attesa gira dentro il giro ([ARC-27]) e dura più blocchi. Docker non
|
||||
riavvia un container `unhealthy`: lo stato serve al monitoraggio
|
||||
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
|
||||
resta affidato a `restart: unless-stopped` quando il processo esce.
|
||||
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
|
||||
`/health`, che risponde sempre `ok` ([ARC-17]).
|
||||
|
||||
### Database
|
||||
|
||||
@@ -67,12 +109,23 @@ flowchart LR
|
||||
proprie sessioni.
|
||||
*Codice:* `app/db/base.py` · `app/db/session.py:get_session`
|
||||
- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati
|
||||
`journal_mode=WAL`, `synchronous=NORMAL` e `busy_timeout=5000` ms: in WAL
|
||||
lettori e scrittori non si bloccano a vicenda, e uno scrittore che trova il
|
||||
database occupato aspetta fino a 5 secondi invece di fallire subito. Con un
|
||||
dialetto diverso da SQLite le pragma non vengono applicate.
|
||||
`journal_mode=WAL`, `synchronous=FULL` e `busy_timeout=5000` ms: in WAL
|
||||
lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il
|
||||
database occupato aspetta fino a 5 secondi invece di fallire subito, e
|
||||
`FULL` fa sì che un commit riuscito sopravviva a una mancanza di corrente
|
||||
(con `NORMAL` si potevano perdere gli ultimi commit, per esempio una
|
||||
registrazione, riusandone poi l'indice di derivazione). Ogni transazione che
|
||||
sposta denaro si apre con `BEGIN IMMEDIATE`, così le letture su cui decide
|
||||
sono isolate e l'attesa del lock avviene all'inizio, non alla prima
|
||||
scrittura; un `database is locked` dopo un invio è gestito esplicitamente
|
||||
(la riga resta al riconciliatore). Con un dialetto diverso da SQLite le
|
||||
pragma non vengono applicate.
|
||||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||||
*Test:* `tests/unit/test_db_base.py`
|
||||
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL`, e pysqlite
|
||||
in modalità legacy emette `BEGIN` solo alla prima scrittura, quindi le
|
||||
letture precedenti non sono isolate e l'attesa del lock può scattare in
|
||||
punti qualsiasi.
|
||||
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
|
||||
tramite migrazioni Alembic in `migrations/versions/` (generate con
|
||||
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
|
||||
@@ -85,9 +138,14 @@ flowchart LR
|
||||
- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`,
|
||||
`uvicorn.access`) finiscono in `logs/app.log` (nel container
|
||||
`/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da
|
||||
10 MB con 5 file di storico, livello `INFO`. La configurazione avviene
|
||||
all'import di `app/main.py`, prima di qualunque altro modulo applicativo.
|
||||
10 MB con 5 file di storico, livello `INFO`, **e anche su stdout**, così
|
||||
`docker compose logs` li mostra senza entrare nel volume. La configurazione
|
||||
avviene all'import di `app/main.py`, prima di qualunque altro modulo
|
||||
applicativo.
|
||||
*Codice:* `app/logging_config.py:setup_logging`
|
||||
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
|
||||
nel file; su console arrivano solo quelli di uvicorn, per la sua
|
||||
configurazione predefinita.
|
||||
|
||||
## Mappa del codice
|
||||
|
||||
@@ -136,17 +194,17 @@ sono vuoti e non sono elencati.
|
||||
|---|---|
|
||||
| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` |
|
||||
| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv |
|
||||
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo |
|
||||
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo. *Da implementare:* indirizzo del montepremi per round (`2/<round_id>`), con `1/0` come indirizzo di riserva (B-80) |
|
||||
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
|
||||
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
|
||||
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS` |
|
||||
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79), UTXO sotto `DUST_LIMIT_SATS` esclusi dalla selezione (B-75) |
|
||||
|
||||
### Rete Electrum
|
||||
|
||||
| File | Responsabilità |
|
||||
|---|---|
|
||||
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints` |
|
||||
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md) |
|
||||
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`. *Da implementare:* limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione (B-75); pin del certificato per endpoint (B-84) |
|
||||
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md). Il docstring della classe cita un metodo `add_address` che non esiste: l'indirizzo di un nuovo utente si aggiunge con `address_for_new_user` |
|
||||
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
|
||||
|
||||
### Dominio
|
||||
@@ -161,7 +219,7 @@ sono vuoti e non sono elencati.
|
||||
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
|
||||
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
|
||||
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
|
||||
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore |
|
||||
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
|
||||
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
|
||||
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
|
||||
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
|
||||
@@ -287,13 +345,20 @@ sequenceDiagram
|
||||
U->>L: startup
|
||||
L->>L: validate_runtime_secrets()
|
||||
L->>L: parse_endpoints(...)
|
||||
L->>L: quorum minimo (B-84, da implementare)
|
||||
L->>L: verifica della master key (B-89, da implementare)
|
||||
L->>L: START_PAUSED (B-90, da implementare)
|
||||
L->>L: app.state.electrum_listener, app.state.user_locks
|
||||
L->>L: create_task ×6
|
||||
Note over L: il server serve richieste
|
||||
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
|
||||
U->>L: shutdown
|
||||
L->>L: cancel() dei 6 task, chiusura del client Electrum
|
||||
L->>L: chiusura dei flussi SSE (B-93, da implementare)
|
||||
L->>L: cancel() dei 6 task (e attesa della fine, B-93), chiusura del client Electrum
|
||||
```
|
||||
|
||||
Il diagramma mostra il comportamento atteso; i passi marcati «da
|
||||
implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
|
||||
### All'import
|
||||
|
||||
- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa
|
||||
@@ -321,34 +386,76 @@ sequenceDiagram
|
||||
*Codice:* `app/main.py:log_unhandled_exception` ·
|
||||
*Test:* `tests/unit/test_rounds_route.py`
|
||||
- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare
|
||||
database né rete.
|
||||
database né rete: dice solo che il processo risponde (*liveness*). Se il
|
||||
servizio è davvero in grado di lavorare lo dice `GET /health/ready`
|
||||
([ARC-61], [api.md](api.md#servizio) (B-92)).
|
||||
*Codice:* `app/main.py:health`
|
||||
- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il
|
||||
file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` →
|
||||
`admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`.
|
||||
Per ultimo, l'intera cartella `app/static/` è montata su `/` con
|
||||
`StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il
|
||||
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…). I percorsi sono relativi
|
||||
alla directory di lavoro, che nel container è `/app`.
|
||||
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…), con
|
||||
`Cache-Control: no-cache` (il browser li riconvalida a ogni caricamento).
|
||||
I percorsi sono relativi alla directory di lavoro, che nel container è
|
||||
`/app`.
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
|
||||
`app/main.py:user_guide` · `app/main.py:report_bug_page`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
|
||||
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
|
||||
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
|
||||
con una pagina nuova.
|
||||
|
||||
### Nel lifespan
|
||||
|
||||
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
|
||||
l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32
|
||||
caratteri o `XPRV_ENCRYPTION_KEY` è vuota, riportando tutti i problemi
|
||||
insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello resta
|
||||
semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore
|
||||
di `Settings`, così import e test non hanno bisogno di segreti veri.
|
||||
caratteri o la chiave di cifratura della master key è vuota (il file
|
||||
indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o è vuoto), riportando tutti i
|
||||
problemi insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||||
resta semplicemente chiuso. Il controllo sta nel lifespan e non in un
|
||||
validatore di `Settings`, così import e test non hanno bisogno di segreti
|
||||
veri.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
|
||||
`XPRV_ENCRYPTION_KEY` non sia vuota.
|
||||
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
|
||||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`; un
|
||||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
||||
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
|
||||
elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene
|
||||
scritto nel log.
|
||||
*Codice:* `app/electrum/client.py:parse_endpoints` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* la sintassi è `host:porta[:notls]`,
|
||||
senza pin del certificato.
|
||||
|
||||
- **[ARC-60]** **Controlli di avvio.** Dopo [ARC-19] e [ARC-20] e prima di
|
||||
creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito
|
||||
impedisce di servire:
|
||||
1. **quorum minimo**: almeno 2 server di riserva, salvo
|
||||
`ALLOW_NO_QUORUM=true` (solo sviluppo, scritto nel log come
|
||||
avvertimento) — regola in
|
||||
[connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-84);
|
||||
2. **master key**: la chiave viene decifrata e l'indirizzo di riserva del
|
||||
montepremi e un campione degli indirizzi utente salvati vengono
|
||||
ricalcolati e confrontati con il database — regola in
|
||||
[sicurezza.md](sicurezza.md#regole-di-avvio) (B-89);
|
||||
3. **avvio in pausa**: con `START_PAUSED=true` la lotteria viene messa in
|
||||
pausa prima che lo scheduler parta — procedura in
|
||||
[operativita.md](operativita.md#ripristino) (B-90).
|
||||
|
||||
Il controllo dei **buchi negli indici di derivazione** dopo un ripristino
|
||||
(i 20 indici successivi al più alto assegnato, che non devono avere
|
||||
cronologia in catena) ha bisogno della rete, quindi non può stare prima
|
||||
dei task: gira alla prima connessione del listener, e finché non si è
|
||||
concluso senza trovare cronologia la registrazione è rifiutata
|
||||
([operativita.md](operativita.md#ripristino), B-90).
|
||||
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-84, B-89, B-90) · *Oggi:* dopo i segreti e gli
|
||||
endpoint il lifespan crea subito i task; la chiave si decifra alla prima
|
||||
registrazione o al primo pagamento.
|
||||
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
|
||||
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
|
||||
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
|
||||
@@ -356,10 +463,17 @@ sequenceDiagram
|
||||
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
|
||||
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
|
||||
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
|
||||
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento
|
||||
ciascun task riceve `cancel()` (senza attenderne la fine) e il client
|
||||
Electrum, se presente, viene chiuso.
|
||||
*Codice:* `app/main.py:lifespan` · *Test:* `tests/unit/test_code_comments.py`
|
||||
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento, in
|
||||
quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn
|
||||
aspetta le connessioni aperte e Docker lo uccide), ciascun task riceve
|
||||
`cancel()` e il lifespan **ne attende la fine**, poi il client Electrum, se
|
||||
presente, viene chiuso. Il compose dà al container `stop_grace_period: 30s`
|
||||
per completare questa sequenza prima del `SIGKILL`.
|
||||
*Codice:* `app/main.py:lifespan` · `app/rounds/events.py:RoundEventBroadcaster` · `docker-compose.yml` ·
|
||||
*Test:* `tests/unit/test_code_comments.py`
|
||||
*Stato:* da implementare (B-93) · *Oggi:* i task ricevono `cancel()` senza
|
||||
attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide
|
||||
il container dopo i 10s predefiniti.
|
||||
|
||||
## Attività in background
|
||||
|
||||
@@ -414,24 +528,53 @@ sequenceDiagram
|
||||
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
|
||||
scatta solo quando tutti i server hanno fallito nello stesso giro.
|
||||
Rotazione, keepalive, validazione e corroborazione sono in
|
||||
[connessione-electrum.md](connessione-electrum.md).
|
||||
[connessione-electrum.md](connessione-electrum.md). Tre cambiamenti di
|
||||
struttura sono attesi dal piano, con le regole in quel documento:
|
||||
- il listener conserva gli ultimi 10 header ricevuti; un salto di più
|
||||
blocchi scarica e valida gli header intermedi, e un header che si
|
||||
concatena a uno di quelli conservati ma non alla punta è una
|
||||
riorganizzazione da seguire, non la fine della sessione (B-74, B-85);
|
||||
- una sessione connessa la cui punta non avanza da 360s, mentre un altro
|
||||
server ne riporta una più alta, viene chiusa e si passa al server
|
||||
successivo (B-86);
|
||||
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o
|
||||
malformata fa fallire la sola richiesta, non la connessione (B-75).
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
|
||||
`app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||||
`app/electrum/client.py:ElectrumClient.connect` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-74, B-75, B-85, B-86) · *Oggi:* il listener
|
||||
tiene solo la punta, chiude la sessione su ogni header che non si concatena,
|
||||
non confronta la propria punta con gli altri server, e il client usa il
|
||||
limite predefinito di 64 KiB di `asyncio.open_connection`, oltre il quale
|
||||
cade l'intera connessione.
|
||||
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
|
||||
`open_new_round_if_needed` e poi avanza il round attivo. L'estrazione
|
||||
(`_close_and_draw`, con l'attesa del blocco successivo) viene eseguita
|
||||
**dentro** il giro: finché dura, lo scheduler non fa altro. Dopo un riavvio
|
||||
un round rimasto in `drawing` non viene ripreso (limite accettato, vedi
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md)). Logica del round in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
|
||||
sia, `drawing` compreso: per un round in `drawing` il giro rientra
|
||||
nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla
|
||||
riga del round (`draw_height`, `participants_hash`, posizioni dei
|
||||
partecipanti), in modo idempotente. L'attesa **resta dentro il giro**:
|
||||
finché dura, lo scheduler non fa altro, il che è corretto perché nessun
|
||||
altro round può aprirsi finché questo non è `closed`. Un'eccezione durante
|
||||
l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato
|
||||
salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del
|
||||
round in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
|
||||
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
|
||||
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
|
||||
`drawing` per sempre
|
||||
([LIM-01](decisioni-e-limiti.md#robustezza-del-ciclo-di-vita)).
|
||||
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
|
||||
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
|
||||
lavorano sulla tabella `pending_transactions`; il riconciliatore parte
|
||||
subito perché un crash durante un invio lascia proprio una riga `building`
|
||||
da risolvere al riavvio. Regole in
|
||||
lavorano sulla tabella `pending_transactions` (e, dal piano, sulla tabella
|
||||
di tutti i txid di ogni riga, B-77); il riconciliatore parte subito perché
|
||||
un crash durante un invio lascia proprio una riga `building` da risolvere
|
||||
al riavvio. Regole in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||||
*Codice:* `app/tx/confirmation.py:ConfirmationPoller` ·
|
||||
`app/tx/broadcast.py:RbfBumper` ·
|
||||
@@ -459,8 +602,10 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
| `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario |
|
||||
| `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]` separati da virgola |
|
||||
| `xprv_encryption_key` | `XPRV_ENCRYPTION_KEY` | `""` | Chiave Fernet della master xprv (obbligatoria, [ARC-19]) |
|
||||
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
|
||||
| `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]) |
|
||||
| `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) |
|
||||
| `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) |
|
||||
| `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT |
|
||||
@@ -469,22 +614,35 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
|
||||
|
||||
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`,
|
||||
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è
|
||||
la variabile `XPRV_ENCRYPTION_KEY` e la sintassi delle riserve non ha
|
||||
`pin=`.
|
||||
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
|
||||
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
||||
indirizzo commissioni, pausa di manutenzione) stanno nell'unica riga della
|
||||
tabella `round_config`, modificabile a caldo da `/admin` senza riavvio. I
|
||||
valori iniziali sono i default delle colonne di `RoundConfig`, non
|
||||
`app/config.py`. Se la riga manca, viene creata al primo accesso con
|
||||
`fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md), effetti
|
||||
in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
||||
depositi `deposit_confirmations`, conferme del blocco di estrazione
|
||||
`draw_confirmations`, tetto al montepremi `max_round_pool_sats`) stanno
|
||||
nell'unica riga della tabella `round_config`, modificabile a caldo da
|
||||
`/admin` senza riavvio. I valori iniziali sono i default delle colonne di
|
||||
`RoundConfig`, non `app/config.py`. Se la riga manca, viene creata al primo
|
||||
accesso con `fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md),
|
||||
effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
*Codice:* `app/rounds/config.py:get_round_config` ·
|
||||
`app/db/models.py:RoundConfig`
|
||||
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:*
|
||||
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il
|
||||
motivo della pausa non esistono.
|
||||
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
|
||||
la ripartizione 70/30, le conferme richieste (1), i limiti di input e di
|
||||
la ripartizione 70/30, le conferme richieste per le transazioni della
|
||||
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
|
||||
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
|
||||
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). Le conferme
|
||||
dei depositi sono invece configurabili ([ARC-31]).
|
||||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||||
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
|
||||
transazione, depositi e coinbase compresi.
|
||||
|
||||
## Aggiornamenti in tempo reale (SSE)
|
||||
|
||||
@@ -551,7 +709,9 @@ flowchart LR
|
||||
`app/withdrawals/service.py:_release_failed_withdrawal`;
|
||||
- transazione confermata — `app/tx/confirmation.py:poll_once`;
|
||||
- riga promossa o abbandonata dal riconciliatore —
|
||||
`app/tx/reconcile.py:reconcile_once`;
|
||||
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
|
||||
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
|
||||
pubblicazione solo sui cambiamenti è in B-96;
|
||||
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
|
||||
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
|
||||
`app/deposits/service.py:mark_utxos_spent_externally`,
|
||||
@@ -559,6 +719,8 @@ flowchart LR
|
||||
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
|
||||
|
||||
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
|
||||
notifica per ogni riga ricontrollata, anche senza cambiamenti.
|
||||
|
||||
## Lock per utente
|
||||
|
||||
@@ -636,11 +798,17 @@ flowchart LR
|
||||
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
|
||||
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
|
||||
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
|
||||
aperte; non hanno un polling periodico proprio. La sezione Parametri non
|
||||
viene ricaricata dagli eventi.
|
||||
aperte; non hanno un polling periodico proprio, quindi se il canale SSE è
|
||||
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
|
||||
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
|
||||
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
|
||||
filtri dell'audit log (B-86, B-92).
|
||||
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
|
||||
`app/static/admin.js:connectAdminEvents` ·
|
||||
`app/static/admin.js:onAdminServerEvent`
|
||||
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
|
||||
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
|
||||
e l'audit log le ultime righe senza filtri.
|
||||
|
||||
### `/report-bug` e `/guida`
|
||||
|
||||
@@ -762,14 +930,50 @@ italiano.
|
||||
`tests/unit/test_code_comments.py` ·
|
||||
`tests/unit/test_caddyfile_security_headers.py` ·
|
||||
`tests/unit/test_withdrawals.py`
|
||||
- **[ARC-58]** Ciò che la suite **non** copre: nessun test contro una
|
||||
connessione Electrum reale; il `lifespan` e l'avvio dei sei task; la route
|
||||
SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua
|
||||
acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei
|
||||
frontend; la parità delle chiavi tra le lingue di `i18n.js`. Le verifiche
|
||||
dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni
|
||||
reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
- **[ARC-58]** Ciò che la suite **non** copre oggi: nessun test contro una
|
||||
connessione Electrum reale né contro guasti di rete realistici (ritardi,
|
||||
cadute a metà richiesta, risposte enormi, catene divergenti); il `lifespan`
|
||||
e l'avvio dei sei task; la route SSE `round_stream` (è testato solo il
|
||||
broadcaster); `UserLocks` e la sua acquisizione nelle route di scommessa e
|
||||
prelievo; il codice JavaScript dei frontend; la parità delle chiavi tra le
|
||||
lingue di `i18n.js`; le migrazioni Alembic (lo schema dei test nasce da
|
||||
`Base.metadata.create_all`). Le verifiche dal vivo sono state manuali
|
||||
(`scripts/electrum_smoke_test.py` e transazioni reali su mainnet; prelievo e
|
||||
RBF mai trasmessi dal vivo, vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
La revisione del 2026-09-28 ha osservato che nessuna delle sue falle
|
||||
critiche sarebbe stata trovata da questa suite, che simula tutto: da qui
|
||||
[ARC-62], [ARC-63] e [ARC-64].
|
||||
- **[ARC-62]** **Test delle migrazioni.** Un test esegue `alembic upgrade head`
|
||||
su un database vuoto e verifica che esistano gli indici su espressione
|
||||
`ix_rounds_single_active` e `ix_users_username_lower`, che l'autogenerate
|
||||
non vede e che `batch_alter_table` su SQLite elimina ricostruendo la
|
||||
tabella con un solo avviso. Ogni migrazione batch su `rounds` o `users` li
|
||||
ricrea esplicitamente.
|
||||
*Codice:* `migrations/env.py` · `migrations/versions/` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni.
|
||||
- **[ARC-63]** **Test di parità delle traduzioni.** Un test legge
|
||||
`TRANSLATIONS` da `app/static/i18n.js` e fallisce se una delle sette lingue
|
||||
ha un insieme di chiavi diverso da quello di `en` ([ARC-51]).
|
||||
*Codice:* `app/static/i18n.js` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (senza B-nn: chiude
|
||||
[LIM-21](decisioni-e-limiti.md#prodotto-e-interfacce)) · *Oggi:* la parità è
|
||||
verificata solo a mano.
|
||||
- **[ARC-64]** **Strato di test di guasto e di integrazione**, in
|
||||
`tests/integration/`, separato dalla suite unitaria: server Electrum
|
||||
simulati a livello di socket che rispondono in ritardo, chiudono la
|
||||
connessione dopo aver ricevuto un invio, mandano risposte oltre 16 MiB,
|
||||
header a difficoltà minima o catene che si biforcano; più il database SQLite
|
||||
su file con scrittori concorrenti. Non sostituisce la prova con denaro vero
|
||||
e guasti provocati di
|
||||
[operativita.md](operativita.md#prova-su-rete-reale), che resta obbligatoria
|
||||
prima dell'apertura al pubblico.
|
||||
*Codice:* `tests/integration/__init__.py` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (fase 7 del
|
||||
[piano](piano-correzioni.md#fase-7--prova-su-rete-reale)) · *Oggi:*
|
||||
`tests/integration/` contiene solo `__init__.py`.
|
||||
|
||||
## Assunzioni di processo singolo
|
||||
|
||||
|
||||
+445
-145
@@ -2,7 +2,7 @@
|
||||
|
||||
Fase 4 del [funzionamento generale](come-funziona.md): come un round si apre,
|
||||
si chiude, estrae un vincitore e lo paga. Tutto è guidato da `RoundScheduler`,
|
||||
che si risveglia ogni 5 secondi.
|
||||
che esegue un passo ogni 5 secondi.
|
||||
|
||||
I requisiti usano il prefisso `RND`, secondo la convenzione di
|
||||
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
|
||||
@@ -10,25 +10,38 @@ I requisiti usano il prefisso `RND`, secondo la convenzione di
|
||||
audit in [audit-log.md](audit-log.md), le attività in background in
|
||||
[architettura.md](architettura.md).
|
||||
|
||||
- **[RND-01]** Lo scheduler esegue un passo ogni 5 secondi
|
||||
(`_TICK_INTERVAL_SECONDS`). Un passo non fa nulla se non c'è connessione a
|
||||
Electrum: senza rete nessun round si apre, si chiude o si paga. Un errore in
|
||||
un passo viene registrato nei log e non ferma i passi successivi.
|
||||
Questo documento descrive il comportamento **atteso** dopo la revisione del
|
||||
2026-09-28. I requisiti che il codice non soddisfa ancora sono marcati
|
||||
*Stato:* da implementare, con una riga *Oggi* che dice cosa fa il codice
|
||||
adesso; l'ordine di implementazione è in
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
|
||||
- **[RND-01]** Lo scheduler esegue un passo, poi attende
|
||||
`_TICK_INTERVAL_SECONDS` (5s) prima del successivo. Un passo non fa nulla
|
||||
se non c'è connessione a Electrum: senza rete nessun round si apre, si
|
||||
chiude, estrae o si paga. Un errore in un passo viene registrato nei log e
|
||||
il passo successivo riparte dallo stato salvato nel database, **in
|
||||
qualunque stato sia il round**, `drawing` compreso ([RND-36]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* un errore durante l'attesa del
|
||||
blocco lascia il round in `drawing` per sempre, perché `_tick` non fa nulla
|
||||
sui round in quello stato.
|
||||
|
||||
## Gli stati
|
||||
|
||||
Diagramma del **comportamento atteso**:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in manutenzione,<br/>indirizzo commissioni impostato
|
||||
[*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in pausa,<br/>configurazione pagabile
|
||||
open --> closing: scade il tempo<br/>(al primo passo dopo la scadenza)
|
||||
closing --> closing: scommesse ancora in volo
|
||||
closing --> closing: scommesse ancora in volo,<br/>oppure altezza della catena non nota
|
||||
closing --> closed: nessun partecipante confermato
|
||||
closing --> drawing: tutte le scommesse confermate
|
||||
drawing --> drawing: blocco non corroborato,<br/>si attende il successivo
|
||||
drawing --> paying_out: nuovo blocco corroborato,<br/>vincitore scelto
|
||||
paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s
|
||||
closing --> drawing: tutte le scommesse confermate:<br/>partecipanti, participants_hash<br/>e draw_height salvati
|
||||
drawing --> drawing: conferme insufficienti, oppure<br/>i server non concordano sull'header:<br/>si richiede la stessa altezza<br/>(anche dopo un errore o un riavvio)
|
||||
drawing --> paying_out: header a draw_height concordato,<br/>vincitore salvato, server_seed rivelato
|
||||
paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s;<br/>oppure bloccato (payout_blocked)
|
||||
paying_out --> closed: pagamento confermato
|
||||
closed --> [*]
|
||||
```
|
||||
@@ -37,8 +50,8 @@ stateDiagram-v2
|
||||
|---|---|
|
||||
| `open` | Accetta scommesse fino alla scadenza |
|
||||
| `closing` | Scadenza passata: nessuna nuova scommessa, si aspettano quelle già inviate |
|
||||
| `drawing` | Partecipanti fissati, si attende il blocco che sceglie il vincitore |
|
||||
| `paying_out` | Vincitore noto, pagamento in costruzione, inviato o in attesa di conferma |
|
||||
| `drawing` | Partecipanti e altezza del blocco fissati, si attendono le conferme del blocco che sceglie il vincitore |
|
||||
| `paying_out` | Vincitore noto, pagamento in verifica, in costruzione, inviato o in attesa di conferma |
|
||||
| `closed` | Concluso (con o senza vincitore) |
|
||||
|
||||
- **[RND-02]** Gli stati sono esattamente quelli della tabella e le sole
|
||||
@@ -67,31 +80,85 @@ richiesta di scommessa (vedi
|
||||
dal suo `closed_at`: per un round pagato è il momento della conferma del
|
||||
pagamento, per un round chiuso senza partecipanti è il momento della
|
||||
chiusura. Serve a lasciare ai giocatori il tempo di vedere il risultato;
|
||||
- la lotteria **non è in manutenzione** (`POST /admin/pause`);
|
||||
- la lotteria **non è in pausa**, né per manutenzione (`POST /admin/pause`)
|
||||
né per una pausa automatica: riorganizzazione della catena profonda almeno
|
||||
2 blocchi ([connessione-electrum.md](connessione-electrum.md) (B-85)),
|
||||
pagamento bloccato ([RND-39]), transazione abbandonata poi confermata
|
||||
([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-78)),
|
||||
avvio con `START_PAUSED=true` ([operativita.md](operativita.md) (B-90)).
|
||||
Il motivo è salvato in `paused_reason`
|
||||
([modello-dati.md](modello-dati.md) (B-85)) e solo l'operatore toglie la
|
||||
pausa;
|
||||
- l'**indirizzo delle commissioni** (`fee_address`) è impostato (un valore
|
||||
di soli spazi conta come vuoto). Senza, il pagamento non si potrebbe
|
||||
costruire, quindi il round non viene nemmeno aperto invece di bloccarsi
|
||||
più avanti con i soldi già nel montepremi.
|
||||
di soli spazi conta come vuoto) e la configurazione corrente permette di
|
||||
pagare anche un round con un solo partecipante ([RND-40]). Altrimenti il
|
||||
pagamento non si potrebbe costruire, quindi il round non viene nemmeno
|
||||
aperto invece di bloccarsi più avanti con i soldi già nel montepremi.
|
||||
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py`
|
||||
- **[RND-05]** All'apertura il round **fotografa** la durata
|
||||
(`round_duration_seconds`, 600s predefiniti) e la pausa
|
||||
(`round_cooldown_seconds`, 30s predefiniti) correnti. Scadenza del round e
|
||||
pausa successiva usano sempre i valori fotografati: una modifica da
|
||||
`/admin` vale dal round successivo, mai per quello in corso.
|
||||
*Stato:* da implementare (B-85, B-79) · *Oggi:* l'unica pausa è quella
|
||||
manuale (`paused`) e `rounds_can_open` controlla solo che `fee_address` non
|
||||
sia vuoto.
|
||||
- **[RND-05]** All'apertura il round **fotografa** i parametri correnti che lo
|
||||
governano: durata (`round_duration_seconds`, 600s predefiniti), pausa
|
||||
successiva (`round_cooldown_seconds`, 30s predefiniti), costo della
|
||||
scommessa (`bet_amount_sats`), indirizzo delle commissioni (`fee_address`),
|
||||
conferme richieste sul blocco di estrazione (`draw_confirmations`, 3
|
||||
predefinite) e tetto al montepremi (`max_round_pool_sats`, 0 = nessun
|
||||
tetto; il rifiuto delle scommesse oltre il tetto è in
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata) (B-95)).
|
||||
Il round usa sempre i valori fotografati: una modifica da `/admin` vale dal
|
||||
round successivo, mai per quello in corso.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py`
|
||||
- **[RND-06]** La pausa di manutenzione e l'indirizzo mancante bloccano solo
|
||||
l'apertura del *prossimo* round: un round già in corso prosegue fino al
|
||||
pagamento, anche se l'indirizzo viene cancellato nel frattempo.
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa)
|
||||
*Stato:* da implementare (B-95, B-73) · *Oggi:* sono fotografate solo durata
|
||||
e pausa; costo della scommessa e `fee_address` sono letti dalla
|
||||
configurazione a ogni uso, `draw_confirmations` e `max_round_pool_sats` non
|
||||
esistono.
|
||||
- **[RND-06]** Pause e configurazione non pagabile bloccano solo l'apertura
|
||||
del *prossimo* round: un round già in corso prosegue fino al pagamento con i
|
||||
valori fotografati all'apertura ([RND-05]), quindi una modifica di
|
||||
`fee_address` durante il round non ne cambia la destinazione della
|
||||
commissione. L'API di amministrazione non permette di svuotare
|
||||
`fee_address` (un valore vuoto non è un indirizzo PLM valido).
|
||||
`GET /rounds/current` espone i due casi separatamente (`lottery_paused`,
|
||||
`lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in
|
||||
[guida-admin.md](guida-admin.md).
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round` ·
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round`, `app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* il pagamento legge `fee_address`
|
||||
dalla configurazione a ogni tentativo: una modifica durante il round
|
||||
sposta la commissione del round in corso, e un `fee_address` vuoto (solo
|
||||
modificando il database a mano) blocca il pagamento con
|
||||
`no fee_address configured`.
|
||||
- **[RND-07]** L'apertura di un round invia una notifica SSE.
|
||||
*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
|
||||
`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]).
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* il round non ha alcun segreto;
|
||||
il seme dipende solo dall'hash del blocco.
|
||||
- **[RND-35]** **Un indirizzo del montepremi per round**: all'apertura il
|
||||
round riceve un proprio indirizzo `m/84'/746'/0'/2/<round_id>`, salvato in
|
||||
`rounds.pool_address` ed esposto da `GET /rounds/current`. Le scommesse del
|
||||
round pagano quell'indirizzo
|
||||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede) (B-80)) e il
|
||||
pagamento spende solo monete di quell'indirizzo ([RND-22]). I round aperti
|
||||
prima di questa modifica mantengono l'indirizzo condiviso
|
||||
`m/84'/746'/0'/1/0`, che diventa l'**indirizzo di riserva** ([RND-38]).
|
||||
Il rifiuto dei prelievi verso indirizzi di montepremi, riserva o
|
||||
commissioni è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo) (B-80).
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py:derive_pool_key` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-80) · *Oggi:* tutti i round usano lo stesso
|
||||
indirizzo `m/84'/746'/0'/1/0` (`derive_pool_key`).
|
||||
|
||||
## 1 · Round aperto
|
||||
|
||||
@@ -109,8 +176,8 @@ accettazione sono in
|
||||
**confermate** — esattamente ciò che l'estrazione considererà e che il
|
||||
pagamento potrà spendere. `jackpot_sats` è già il 70% del vincitore
|
||||
(arrotondato per difetto) della somma degli importi netti, non l'intero
|
||||
montepremi; resta un limite superiore, perché la commissione del pagamento
|
||||
sarà dedotta da lì;
|
||||
montepremi; resta un limite superiore, perché la commissione di rete del
|
||||
pagamento sarà dedotta da lì;
|
||||
- `pending_jackpot_sats` e `pending_participant_count`: gli stessi valori
|
||||
includendo le scommesse ancora in volo (`building`, `broadcast`), come
|
||||
totali e non come differenze; `has_pending_bets` dice se differiscono.
|
||||
@@ -128,169 +195,345 @@ giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato.
|
||||
|
||||
## 2 · Chiusura
|
||||
|
||||
- **[RND-11]** Al primo passo dello scheduler dopo la scadenza (quindi entro
|
||||
circa 5 secondi) il round passa a `closing`, qualunque cosa stia accadendo:
|
||||
si annota `closed_at` e si invia una notifica SSE. Le scommesse sono però
|
||||
rifiutate già dall'istante della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)).
|
||||
- **[RND-11]** Al primo passo dello scheduler che inizia dopo la scadenza (i
|
||||
passi sono separati da `_TICK_INTERVAL_SECONDS` = 5s di attesa) il round
|
||||
passa a `closing`, qualunque cosa stia accadendo: si annota `closed_at` e si
|
||||
invia una notifica SSE. Le scommesse sono però rifiutate già dall'istante
|
||||
della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* `tests/unit/test_scheduler.py` (verifica solo lo stato finale; nessun test controlla `closed_at` né la notifica SSE)
|
||||
- **[RND-12]** Poi il round **attende che ogni scommessa già inviata sia
|
||||
confermata** (il "semaforo giallo"): finché esiste un partecipante in stato
|
||||
`building` o `broadcast` il round resta `closing` e il controllo si ripete
|
||||
al passo successivo. Il controllo viene ripetuto anche nella stessa sessione
|
||||
del database in cui si fissa l'elenco dei partecipanti, così una scommessa
|
||||
salvata tra i due controlli non può restare esclusa. Una scommessa partita
|
||||
un attimo prima della scadenza resta quindi valida e non va persa. Ciò che
|
||||
impedisce all'attesa di durare per sempre è il riconciliatore, che prima o
|
||||
poi abbandona una scommessa che non conferma mai
|
||||
([TX-17](transazioni-e-riconciliazione.md#riconciliazione)).
|
||||
un attimo prima della scadenza resta quindi valida e non va persa.
|
||||
**L'attesa non ha un limite di tempo**: finisce solo quando ogni scommessa
|
||||
in volo è confermata oppure la sua riga viene abbandonata dal
|
||||
riconciliatore, che la abbandona solo con una prova
|
||||
([TX-17](transazioni-e-riconciliazione.md#riconciliazione), B-78). Una scommessa
|
||||
che resta nel mempool non viene mai abbandonata: a farla confermare è
|
||||
l'aumento della commissione
|
||||
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)),
|
||||
pagato dall'utente.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo il secondo controllo, in `_close_and_draw`; nessun test copre l'attesa in `_tick` con una scommessa in volo)
|
||||
*Stato:* da implementare (B-78) · *Oggi:* l'attesa è già senza limite, ma
|
||||
il riconciliatore abbandona una scommessa anche solo perché la cronologia
|
||||
del server attivo non contiene il suo txid, senza altra prova.
|
||||
- **[RND-13]** Quando non resta nulla in volo e **nessun partecipante è
|
||||
confermato**, il round passa a `closed` senza vincitore, si scrive
|
||||
`round_closed` (con `participants: 0`) e si invia una notifica SSE.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-14]** Con **almeno un partecipante confermato**, si fissa l'elenco dei
|
||||
partecipanti confermati, ordinato per momento di invio della scommessa, si
|
||||
somma il montepremi dai loro importi netti, e il round passa a `drawing`
|
||||
annotando `drawing_started_at`; si invia una notifica SSE. L'altezza della
|
||||
catena nota in quell'istante diventa il riferimento dell'estrazione.
|
||||
- **[RND-14]** Con **almeno un partecipante confermato**, nella stessa
|
||||
transazione del database che porta il round a `drawing`:
|
||||
- si fissa l'elenco dei partecipanti confermati, **ordinato per txid della
|
||||
scommessa** in ordine crescente (txid in esadecimale minuscolo, nell'ordine
|
||||
mostrato dagli explorer; due scommesse non hanno mai lo stesso txid), e si
|
||||
salva su ogni partecipante la sua posizione `draw_position`, da 0 a n−1;
|
||||
- si calcola e si salva `participants_hash` = sha256 delle righe
|
||||
`txid:vout:indirizzo`, una per partecipante nell'ordine di
|
||||
`draw_position`, dove `vout` è l'uscita della scommessa verso l'indirizzo
|
||||
del montepremi del round (in decimale) e `indirizzo` è quello
|
||||
dell'utente; ogni riga termina con `\n`, testo UTF-8;
|
||||
- si salva l'altezza del blocco di estrazione ([RND-33]);
|
||||
- si somma il montepremi dagli importi netti e si annota
|
||||
`drawing_started_at`.
|
||||
|
||||
Dopo il commit si invia una notifica SSE; da quel momento `participants_hash`
|
||||
e `draw_height` sono pubblici ([RND-37]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* — (nessun test porta `_close_and_draw` con partecipanti confermati fino a `drawing`)
|
||||
*Stato:* da implementare (B-73, B-81) · *Oggi:* l'elenco è ordinato per
|
||||
`broadcast_at` (che il pubblico non vede e che non ha spareggio) e resta
|
||||
solo in memoria; non esistono `draw_position` né `participants_hash`.
|
||||
- **[RND-33]** L'altezza del blocco di estrazione è fissata all'ingresso in
|
||||
`drawing` come `draw_height = tip_at_close + 1`, dove `tip_at_close` è
|
||||
l'altezza della catena nota in quell'istante, e non cambia più: né per una
|
||||
conferma mancata, né per un errore, né per un riavvio.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* si usa la punta vista al primo
|
||||
controllo utile, che può essere un blocco successivo; dopo una
|
||||
corroborazione fallita il riferimento avanza all'altezza rifiutata.
|
||||
|
||||
## 3 · Estrazione
|
||||
|
||||
L'estrazione usa come fonte di casualità l'**hash di un blocco confermato
|
||||
dopo la chiusura**, un valore che nessuno conosceva mentre le scommesse erano
|
||||
aperte.
|
||||
L'estrazione combina tre valori che nessuno conosce tutti insieme prima che
|
||||
sia troppo tardi per cambiarli:
|
||||
|
||||
- il **segreto del server** (`server_seed`), fissato all'apertura e noto solo
|
||||
al server, ma impegnato pubblicamente con la sua impronta ([RND-34]): il
|
||||
server non può cambiarlo dopo;
|
||||
- l'**hash del blocco** all'altezza `draw_height`, che non esisteva quando
|
||||
partecipanti e altezza sono stati fissati;
|
||||
- l'**impronta dei partecipanti** (`participants_hash`), pubblicata prima che
|
||||
quel blocco esista ([RND-14]).
|
||||
|
||||
Chi produce o trasmette i blocchi non conosce il segreto, quindi non sa quale
|
||||
blocco farebbe vincere chi: non ha motivo di nasconderne o sceglierne uno.
|
||||
L'operatore conosce il segreto ma non il blocco, e non può più cambiare né
|
||||
il segreto né l'elenco. Diagramma del **comportamento atteso**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Altezza nota alla chiusura<br/>(se sconosciuta: la prima che si<br/>viene a sapere, draw_baseline_tip_unknown)"] --> B{"La punta della catena<br/>è più alta?<br/>(controllo ogni 5s)"}
|
||||
A["Ingresso in drawing: salvati<br/>partecipanti ordinati per txid,<br/>participants_hash, draw_height = tip + 1"] --> B{"La catena ha draw_confirmations<br/>blocchi a partire da draw_height?<br/>(controllo a ogni passo)"}
|
||||
B -- "No" --> B
|
||||
B -- "Sì" --> C{"La maggioranza degli<br/>altri server conferma<br/>l'hash a quell'altezza?"}
|
||||
C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed),<br/>quell'altezza diventa il nuovo riferimento"] --> B
|
||||
C -- "Sì" --> E["seme = hash come numero intero<br/>indice = seme mod numero partecipanti"]
|
||||
E --> F["Vincitore = partecipante all'indice"]
|
||||
B -- "Sì" --> C{"Tutti i server interrogati<br/>sull'header a draw_height:<br/>il quorum concorda?"}
|
||||
C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed);<br/>al passo successivo si richiede<br/>la stessa altezza"] --> B
|
||||
C -- "Sì" --> E["seme = sha256(server_seed ‖ hash del blocco ‖ participants_hash)<br/>indice = seme mod numero partecipanti"]
|
||||
E --> F["Vincitore = partecipante con draw_position = indice,<br/>salvato; poi server_seed rivelato"]
|
||||
```
|
||||
|
||||
- **[RND-15]** L'estrazione attende che la punta della catena superi
|
||||
l'altezza di riferimento, controllando ogni 5 secondi; il blocco candidato è
|
||||
la punta nota in quel momento (di norma il primo blocco dopo la chiusura).
|
||||
Per tutta l'attesa lo scheduler resta dentro l'estrazione e non esegue
|
||||
altri passi.
|
||||
- **[RND-15]** L'estrazione attende che il blocco a `draw_height` abbia
|
||||
`draw_confirmations` conferme (il valore fotografato all'apertura,
|
||||
[RND-05]), cioè che l'altezza della catena sia almeno
|
||||
`draw_height + draw_confirmations − 1`. La condizione si controlla a ogni
|
||||
passo dello scheduler, rileggendo dal database tutto ciò che serve
|
||||
([RND-36]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-16]** **Blocco corroborato**: l'header ricevuto dal server Electrum
|
||||
attivo è l'unica fonte di casualità, quindi un server malevolo che lo
|
||||
falsificasse sceglierebbe il vincitore. Per questo, prima di usarlo, lo si fa
|
||||
confermare dagli altri server
|
||||
([ELC-15](connessione-electrum.md#5-il-quorum)). Se la conferma manca, si
|
||||
scrive `draw_header_corroboration_failed` (altezza e hash riportato), il
|
||||
blocco non viene mai usato e si attende un blocco ulteriore.
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
|
||||
*Stato:* da implementare (B-73, B-81) · *Oggi:* si attende che la punta
|
||||
superi l'altezza di riferimento e si usa la punta stessa, con una sola
|
||||
conferma; per tutta l'attesa lo scheduler resta bloccato dentro
|
||||
`_close_and_draw` e non esegue altri passi.
|
||||
- **[RND-16]** **Header concordato**: raggiunte le conferme, l'header
|
||||
all'altezza `draw_height` (e non la punta corrente) viene chiesto a tutti i
|
||||
server configurati, attivo compreso, e lo si usa solo se il quorum concorda
|
||||
sullo stesso hash, con il numero minimo di risposte fissato in
|
||||
[connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-84). Se
|
||||
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.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-17]** **Algoritmo**: seme = hash del blocco letto come numero
|
||||
esadecimale, indice = seme modulo numero dei partecipanti, vincitore = il
|
||||
partecipante in quella posizione dell'elenco ordinato per momento di invio.
|
||||
**Probabilità uguale per tutti**: ogni partecipante ha una e una sola
|
||||
posizione nell'elenco, indipendentemente dall'importo.
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
|
||||
*Stato:* da implementare (B-73) · *Oggi:* si usa l'header della punta
|
||||
ricevuto dal server attivo, chiedendo agli altri solo di confermarlo; se la
|
||||
conferma manca, si attende un blocco ulteriore e si usa quello, così il
|
||||
server attivo può scegliere quale blocco reale seminare tacendone uno.
|
||||
- **[RND-17]** **Algoritmo**: seme = sha256 della concatenazione di
|
||||
`server_seed` (32 byte), hash del blocco a `draw_height` (32 byte,
|
||||
nell'ordine mostrato dagli explorer) e `participants_hash` (32 byte),
|
||||
letto come intero senza segno big-endian; indice = seme modulo numero dei
|
||||
partecipanti; vincitore = il partecipante con `draw_position` uguale
|
||||
all'indice. **Probabilità uguale per tutti**: ogni partecipante ha una e
|
||||
una sola posizione nell'elenco, indipendentemente dall'importo.
|
||||
*Codice:* `app/rounds/draw.py:draw_winner`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_draw.py`
|
||||
- **[RND-18]** **Verificabile**: il round salva altezza e hash del blocco
|
||||
(`draw_block_height`, `draw_block_hash`), il seme (`seed_int`), il
|
||||
vincitore e il montepremi (`pool_amount_sats`), passa a `paying_out`,
|
||||
scrive `winner_drawn` (indirizzo del vincitore, montepremi, altezza, hash,
|
||||
numero di partecipanti) e invia una notifica SSE. Chiunque, con l'hash del
|
||||
blocco e l'elenco dei partecipanti, può rifare il calcolo. Il primo
|
||||
tentativo di pagamento parte subito dopo.
|
||||
*Test:* `tests/unit/test_draw.py` (solo `draw_winner`; nessun test lato scheduler)
|
||||
*Stato:* da implementare (B-73) · *Oggi:* seme = hash del blocco letto come
|
||||
numero esadecimale, applicato all'elenco ordinato per `broadcast_at`.
|
||||
- **[RND-18]** **Verificabile**: in un'unica transazione del database il
|
||||
round salva altezza e hash del blocco (`draw_block_height` = `draw_height`,
|
||||
`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
|
||||
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
|
||||
tentativo di pagamento parte subito dopo, nello stesso passo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-19]** **Altezza iniziale sconosciuta**: se alla chiusura l'altezza
|
||||
della catena non era nota (valore 0), non si parte da zero (qualunque blocco
|
||||
già esistente sarebbe "successivo", e quindi prevedibile): si attende il
|
||||
primo header, se ne adotta l'altezza come riferimento, si scrive
|
||||
`draw_baseline_tip_unknown` e si aspetta un blocco strettamente successivo.
|
||||
In condizioni normali non accade, perché la connessione è resa disponibile
|
||||
solo con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* — (nessun test porta `_close_and_draw` con partecipanti confermati fino a `paying_out`)
|
||||
*Stato:* da implementare (B-73) · *Oggi:* il calcolo non è ripetibile da
|
||||
terzi: l'ordine dei partecipanti è per `broadcast_at`, noto solo al server
|
||||
e senza spareggio fra valori uguali.
|
||||
- **[RND-19]** **Altezza sconosciuta alla chiusura**: se, quando il round
|
||||
sarebbe pronto per `drawing`, l'altezza della catena non è nota (valore 0),
|
||||
il round resta `closing` e il passaggio viene ritentato al passo
|
||||
successivo, scrivendo `draw_baseline_tip_unknown` una volta per round:
|
||||
`draw_height` si fissa solo su un'altezza realmente nota ([RND-33]). In
|
||||
condizioni normali non accade, perché la connessione è resa disponibile solo
|
||||
con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)).
|
||||
*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`, attende il primo header, ne adotta l'altezza come riferimento,
|
||||
scrive `draw_baseline_tip_unknown` e aspetta un blocco successivo.
|
||||
- **[RND-20]** **Attesa lunga ma visibile**: l'attesa non ha un limite,
|
||||
perché dipende davvero dall'arrivo di un blocco. Ogni 60 secondi se ne
|
||||
registra l'avanzamento nei log; dopo 6 minuti (`_DRAW_STALL_THRESHOLD_SECONDS`
|
||||
= 360s) viene scritto un evento `draw_stalled`, ripetuto ogni 6 minuti
|
||||
finché l'attesa continua. Durante `drawing`, `GET /rounds/current` espone
|
||||
`draw_waiting_since` (l'orario di inizio dell'attesa); negli altri stati il
|
||||
campo è assente.
|
||||
perché dipende davvero dall'arrivo dei blocchi. Ogni 60 secondi
|
||||
(`_DRAW_PROGRESS_LOG_INTERVAL_SECONDS`) se ne registra l'avanzamento nei
|
||||
log. Se per 360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`) la catena non
|
||||
avanza mentre il round attende le conferme, oppure il quorum continua a non
|
||||
concordare, viene scritto un evento `draw_stalled`, ripetuto ogni 360
|
||||
secondi finché la situazione dura. Durante `drawing`, `GET /rounds/current`
|
||||
espone `draw_waiting_since` (l'orario di inizio dell'attesa); negli altri
|
||||
stati il campo è assente.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/api/routes/rounds.py:current_round` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_route.py`
|
||||
*Stato:* da implementare (B-73) · *Oggi:* i 360 secondi si contano
|
||||
dall'inizio dell'attesa; con 3 conferme richieste l'evento scatterebbe in
|
||||
quasi ogni round.
|
||||
- **[RND-36]** **Estrazione ripresa**: tutto ciò che l'estrazione usa
|
||||
(`server_seed`, elenco con `draw_position`, `participants_hash`,
|
||||
`draw_height`, `draw_confirmations`) è nel database dall'ingresso in
|
||||
`drawing`. A ogni passo, per un round in `drawing`, lo scheduler rifà il
|
||||
controllo di [RND-15] da quei valori, anche dopo un errore in un passo
|
||||
precedente o un riavvio del server. La ripresa è idempotente: l'estrazione
|
||||
ripresa dà sempre lo stesso vincitore, e un round già passato a
|
||||
`paying_out` non viene estratto di nuovo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-81) · *Oggi:* elenco dei partecipanti e altezza
|
||||
di riferimento esistono solo in memoria e `_tick` salta i round in
|
||||
`drawing`: dopo un errore o un riavvio il round resta in `drawing` per
|
||||
sempre.
|
||||
- **[RND-37]** **Prova pubblica**: `GET /rounds/{id}/proof`, senza
|
||||
autenticazione, restituisce per un round: `server_seed_hash` e
|
||||
`pool_address` (dall'apertura); dall'ingresso in `drawing` l'elenco dei
|
||||
partecipanti (`draw_position`, txid, `vout`, indirizzo, importo netto),
|
||||
`participants_hash`, `draw_height` e `draw_confirmations`; dal salvataggio
|
||||
del vincitore `server_seed`, `draw_block_hash`, il seme, l'indice, il
|
||||
vincitore e, quando esiste, il txid del pagamento. Un campo non ancora
|
||||
disponibile è `null`. Il formato della risposta è in [api.md](api.md) (B-73).
|
||||
*Codice:* `app/api/routes/rounds.py` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* non esiste alcun endpoint di
|
||||
prova; `GET /rounds/current` non espone partecipanti né dati
|
||||
dell'estrazione, e i round chiusi non sono consultabili pubblicamente.
|
||||
|
||||
L'algoritmo è volutamente semplice ed è pensato per essere **sostituito**
|
||||
(vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
### Come si verifica un'estrazione
|
||||
|
||||
Chiunque può controllare un round concluso con la sola prova pubblica e un
|
||||
qualunque nodo o explorer PLM, senza fidarsi del server:
|
||||
|
||||
1. **Impegno.** Annotare `server_seed_hash` mentre il round è aperto (è
|
||||
pubblico dall'apertura). Dopo la rivelazione, verificare che
|
||||
`sha256(server_seed)` sia uguale a quel valore: il segreto usato è quello
|
||||
fissato all'apertura.
|
||||
2. **Partecipanti.** Per ogni riga dell'elenco verificare sulla catena che la
|
||||
transazione `txid` esista, sia confermata, abbia l'uscita `vout` verso
|
||||
`pool_address` con l'importo indicato e sia firmata dall'indirizzo
|
||||
indicato. Poiché l'indirizzo del montepremi è del solo round ([RND-35]),
|
||||
la sua cronologia mostra anche che nessuna scommessa confermata prima
|
||||
della chiusura manca dall'elenco.
|
||||
3. **Ordine.** Verificare che le righe siano in ordine crescente di txid e
|
||||
che `draw_position` vada da 0 a n−1 senza salti.
|
||||
4. **Impronta dei partecipanti.** Ricostruire le righe `txid:vout:indirizzo`
|
||||
(ognuna terminata da `\n`), calcolarne lo sha256 e confrontarlo con
|
||||
`participants_hash`. Chi ha osservato la prova durante `drawing` sa anche
|
||||
che impronta e `draw_height` erano pubblici prima che il blocco esistesse.
|
||||
5. **Blocco.** Verificare che `draw_block_hash` sia l'hash del blocco
|
||||
all'altezza `draw_height` della catena principale.
|
||||
6. **Calcolo.** Calcolare sha256 della concatenazione dei 32 byte di
|
||||
`server_seed`, dei 32 byte dell'hash del blocco e dei 32 byte di
|
||||
`participants_hash`; leggerlo come intero big-endian; l'indice è quel
|
||||
numero modulo n. Il vincitore dichiarato deve essere la riga con quella
|
||||
`draw_position`.
|
||||
7. **Pagamento.** Verificare che la transazione di pagamento spenda le
|
||||
scommesse del round e paghi l'indirizzo del vincitore
|
||||
([RND-21], [RND-23]).
|
||||
|
||||
Il controllo non esclude un operatore che sia anche minatore: conoscendo il
|
||||
segreto potrebbe scegliere quale blocco pubblicare a `draw_height`. È un
|
||||
rischio accettato, descritto in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md#round-ed-estrazione) (B-73).
|
||||
L'algoritmo resta un componente **sostituibile** (vedi
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
|
||||
## 4 · Pagamento
|
||||
|
||||
Con il vincitore noto il round è in `paying_out` e si costruisce il
|
||||
pagamento, firmato con la chiave del montepremi.
|
||||
Con il vincitore noto il round è in `paying_out`: si verificano le monete del
|
||||
round, si costruisce il pagamento e lo si firma con la chiave dell'indirizzo
|
||||
del montepremi del round.
|
||||
|
||||
- **[RND-21]** **Ripartizione**: il vincitore riceve il 70% del montepremi
|
||||
arrotondato per difetto, l'indirizzo delle commissioni il resto (30% più
|
||||
l'eventuale resto dell'arrotondamento). La ripartizione è fissata nel
|
||||
codice, non modificabile da `/admin`.
|
||||
verificato ([RND-39]) arrotondato per difetto, meno la commissione di rete
|
||||
([RND-23]); l'indirizzo delle commissioni riceve il resto (30% più
|
||||
l'eventuale resto dell'arrotondamento) meno la riserva per l'RBF
|
||||
([RND-38]). La ripartizione è fissata nel codice, non modificabile da
|
||||
`/admin`.
|
||||
*Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`
|
||||
- **[RND-22]** Il pagamento spende le monete **confermate** dell'indirizzo del
|
||||
montepremi, lette dal server Electrum attivo ed escluse quelle già
|
||||
impegnate da un altro pagamento non risolto ([RND-26]), scelte dalla più
|
||||
grande fino a coprire il montepremi e al massimo 500
|
||||
(`MAX_PAYOUT_TX_INPUTS`). Non abbastanza monete dà il motivo
|
||||
`insufficient pool UTXOs`, troppe `too_many_inputs`.
|
||||
*Stato:* da implementare (B-79, B-82) · *Oggi:* la ripartizione si calcola
|
||||
su `pool_amount_sats` fissato all'estrazione e l'indirizzo delle
|
||||
commissioni riceve l'intera quota del 30%.
|
||||
- **[RND-22]** Il pagamento spende le uscite delle scommesse dei partecipanti
|
||||
del round **verificate** ([RND-39]), all'indirizzo del montepremi del round
|
||||
([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non
|
||||
vengono toccate. Sono escluse quelle già impegnate da un altro pagamento
|
||||
non risolto ([RND-26]); il limite è di 500 ingressi
|
||||
(`MAX_PAYOUT_TX_INPUTS`), superiore al massimo di partecipanti per round
|
||||
([BET-06](scommesse-e-prelievi.md#quando-è-accettata)). Troppi ingressi dà
|
||||
il motivo `too_many_inputs`. Per i round aperti prima dell'indirizzo per
|
||||
round si spendono le monete confermate dell'indirizzo condiviso, scelte
|
||||
dalla più grande fino a coprire il montepremi; non abbastanza monete dà
|
||||
`insufficient pool UTXOs`.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`
|
||||
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore, una
|
||||
verso l'indirizzo delle commissioni e, se supera 294 sat, una di resto verso
|
||||
il montepremi (un resto inferiore va alla commissione di rete). La
|
||||
*Stato:* da implementare (B-80, B-82) · *Oggi:* spende le monete confermate
|
||||
dell'indirizzo condiviso lette dal solo server attivo, scelte dalla più
|
||||
grande fino a coprire il montepremi, qualunque sia la loro provenienza.
|
||||
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore,
|
||||
una verso l'indirizzo delle commissioni fotografato all'apertura
|
||||
([RND-05]) e una verso l'indirizzo di riserva ([RND-38]). Se gli ingressi
|
||||
superano quanto serve (solo per i round aperti prima dell'indirizzo per
|
||||
round), il resto va all'indirizzo di riserva se è di almeno 294 sat
|
||||
(`DUST_LIMIT_SATS`), altrimenti va alla commissione di rete. La
|
||||
**commissione di rete** (stimata su tre uscite, a `fee_rate_sat_vb`) è
|
||||
pagata dalla quota del vincitore, così il 30% resta intatto. Se la quota del
|
||||
vincitore meno la commissione, o la quota delle commissioni, scende sotto
|
||||
294 sat, il pagamento non si costruisce (`winner_share_below_network_fee`,
|
||||
pagata dalla quota del vincitore, così la quota delle commissioni non la
|
||||
paga. Se la quota del vincitore meno la commissione di rete, o la quota
|
||||
delle commissioni meno la riserva, è inferiore a 294 sat, il pagamento non
|
||||
si costruisce (`winner_share_below_network_fee`,
|
||||
`commission_below_dust_limit`).
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`
|
||||
*Test:* `tests/unit/test_payout_builder.py` (nessun test copre `commission_below_dust_limit`)
|
||||
*Stato:* da implementare (B-79, B-95) · *Oggi:* la terza uscita è un resto
|
||||
verso l'indirizzo del montepremi, presente solo se è di almeno 294 sat e
|
||||
assente quando le monete spese sono esattamente le scommesse del round; non c'è riserva, la soglia di 294 sat si applica all'intera
|
||||
quota delle commissioni e `fee_address` è letto dalla configurazione.
|
||||
- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round
|
||||
registra importo del vincitore (`winner_amount_sats`), commissioni
|
||||
(`fee_amount_sats`) e txid (`payout_txid`), e si salva una transazione in
|
||||
(`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e
|
||||
txid (`payout_txid`), e si salva una transazione in
|
||||
corso di tipo `payout` in stato `building`. Se l'invio riesce, la
|
||||
transazione passa a `pending` e si scrive `payout_sent`. Se fallisce, la
|
||||
riga resta `building` e si scrive `payout_failed`: sarà il riconciliatore a
|
||||
constatare che la rete non la conosce, abbandonarla e liberare il round per
|
||||
un nuovo tentativo ([TX-18](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)).
|
||||
transazione passa a `pending` e si scrive `payout_sent`. Se non riesce, si
|
||||
scrive `payout_failed` e la riga resta `building`: la risolve il
|
||||
riconciliatore, secondo le regole di invio e di abbandono di
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)
|
||||
(TX-18, B-76, B-78); solo un abbandono libera il round per un nuovo
|
||||
tentativo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-25]** Ogni tentativo che non arriva all'invio riuscito scrive un
|
||||
evento `payout_failed` con il motivo: `electrum client not connected`,
|
||||
*Stato:* da implementare (B-79) · *Oggi:* tutto come descritto, tranne
|
||||
`bump_reserve_sats`, che non esiste.
|
||||
- **[RND-25]** Ogni tentativo che si ferma prima di un invio riuscito scrive
|
||||
un evento `payout_failed` con il motivo: `electrum client not connected`,
|
||||
`no fee_address configured`, `winner user not found`,
|
||||
`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`. A ogni passo lo scheduler riesamina un
|
||||
round in `paying_out` e riprova **al massimo una volta ogni 60 secondi**
|
||||
(`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo `payout_failed` di
|
||||
quel round; senza alcun `payout_failed` (ad esempio dopo un riavvio) riprova
|
||||
subito.
|
||||
di costruzione, `broadcast rejected`. 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
|
||||
riesamina un round in `paying_out` e riprova **al massimo una volta ogni 60
|
||||
secondi** (`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo
|
||||
`payout_failed` di quel round; senza alcun `payout_failed` (ad esempio dopo
|
||||
un riavvio, o mentre un pagamento è in volo) riprova al passo successivo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* `tests/unit/test_scheduler.py` (verificati solo i motivi `electrum client not connected` e `no fee_address configured`; per l'invio fallito solo la presenza di `payout_failed`)
|
||||
- **[RND-26]** Due protezioni impediscono di pagare due volte: non si
|
||||
costruisce un nuovo pagamento se per il round ne esiste già uno `building` o
|
||||
`pending` (il tentativo termina senza fare nulla), e le monete del
|
||||
montepremi impegnate da un qualunque pagamento non risolto, anche di un
|
||||
`pending` (il tentativo termina senza fare nulla e senza scrivere eventi),
|
||||
e le monete impegnate da un qualunque pagamento non risolto, anche di un
|
||||
round precedente, sono escluse dalla selezione.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
- **[RND-27]** Se il pagamento non conferma in tempo si applica l'RBF, pagato
|
||||
dal resto del montepremi ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
|
||||
riducendo l'uscita di riserva ([RND-38]); le uscite del vincitore e delle
|
||||
commissioni non cambiano
|
||||
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`
|
||||
*Stato:* da implementare (B-79) · *Oggi:* l'RBF può ridurre solo il resto
|
||||
verso il montepremi, che non esiste quando le monete spese sono
|
||||
esattamente le scommesse del round: un pagamento bloccato nel
|
||||
mempool non si può accelerare, e poiché il server lo conosce non viene
|
||||
nemmeno abbandonato, quindi il round resta in `paying_out` e nessun nuovo
|
||||
round si apre.
|
||||
- **[RND-28]** Quando il pagamento è confermato il round passa a `closed` e
|
||||
`closed_at` viene riscritto con l'orario della conferma: da lì parte la
|
||||
pausa prima del round successivo. Il vincitore si vede accreditare la
|
||||
@@ -298,18 +541,77 @@ pagamento, firmato con la chiave del montepremi.
|
||||
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
|
||||
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` ·
|
||||
*Test:* `tests/unit/test_confirmation.py`
|
||||
- **[RND-38]** **Riserva per l'RBF**: ogni pagamento ha un'uscita di riserva
|
||||
verso l'indirizzo di riserva `m/84'/746'/0'/1/0` di valore
|
||||
`max(DUST_LIMIT_SATS, estimate_vsize(ingressi, 3) × 10 sat/vB)`, dove 10
|
||||
sat/vB è la costante `PAYOUT_BUMP_RESERVE_SAT_VB`. La riserva è tolta
|
||||
dalla quota delle commissioni, mai da quella del vincitore; è ciò che
|
||||
l'RBF riduce ([RND-27]).
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction`, `app/wallet/hd.py:derive_pool_key` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-79) · *Oggi:* non esiste alcuna riserva.
|
||||
- **[RND-39]** **Verifica prima del pagamento**: prima di costruire il
|
||||
pagamento, le uscite delle scommesse dei partecipanti vengono verificate
|
||||
all'indirizzo del montepremi del round con il quorum dei server
|
||||
([connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-82)).
|
||||
- Un partecipante la cui uscita il quorum dice **assente** (la scommessa è
|
||||
sparita dalla catena, ad esempio per una riorganizzazione) passa a
|
||||
`reorged_out` ([modello-dati.md](modello-dati.md) (B-82)) ed è escluso
|
||||
dal montepremi; `pool_amount_sats` viene riscritto con la somma delle
|
||||
uscite verificate e la ripartizione ([RND-21]) si calcola su quella.
|
||||
- Se è assente la scommessa **del vincitore**, il pagamento non si
|
||||
costruisce: si scrive `payout_blocked`, si annota il blocco sul round,
|
||||
la lotteria va in pausa automatica ([RND-04]) e il round resta in
|
||||
`paying_out` senza altri tentativi finché l'operatore non toglie la pausa
|
||||
(`POST /admin/resume`); al tentativo successivo la verifica si ripete. Lo
|
||||
schema è in [modello-dati.md](modello-dati.md) (B-82), la procedura in
|
||||
[operativita.md](operativita.md) (B-82).
|
||||
- Se il quorum non risponde o non concorda, non si conclude nulla: nessun
|
||||
partecipante cambia stato, il tentativo termina con `payout_failed` e si
|
||||
riprova secondo [RND-25].
|
||||
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* le monete sono lette dal solo
|
||||
server attivo e nessun partecipante viene mai escluso: se una scommessa
|
||||
sparisce dalla catena il pagamento fallisce con
|
||||
`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 ≥
|
||||
DUST_LIMIT_SATS`;
|
||||
- `netto − winner_share(netto) − riserva ≥ DUST_LIMIT_SATS`, con la
|
||||
riserva di [RND-38] calcolata su un ingresso.
|
||||
|
||||
Si usano i valori della configurazione al momento dell'apertura.
|
||||
*Codice:* `app/rounds/service.py:rounds_can_open` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo
|
||||
che `fee_address` non sia vuoto.
|
||||
|
||||
## Tempi reali
|
||||
|
||||
Tra lo scadere del timer e l'arrivo della vincita ci sono **tre conferme
|
||||
distinte**, ognuna in un blocco diverso:
|
||||
Tra lo scadere del timer e l'arrivo della vincita ci sono **tre attese di
|
||||
conferma distinte**:
|
||||
|
||||
1. l'ultima scommessa in volo (può essere già avvenuta prima della scadenza);
|
||||
2. il blocco dell'estrazione, necessariamente successivo al punto 1;
|
||||
3. il pagamento, costruito solo dopo il punto 2.
|
||||
1. l'ultima scommessa in volo (può essere già confermata prima della
|
||||
scadenza);
|
||||
2. il blocco dell'estrazione a `draw_height`, il primo dopo il punto 1, più
|
||||
i blocchi successivi fino a `draw_confirmations` conferme (3 predefinite,
|
||||
quindi 3 blocchi);
|
||||
3. il pagamento, costruito solo dopo il punto 2, che richiede un altro blocco.
|
||||
|
||||
Con blocchi da 120 secondi sono circa **2–4 minuti** nel caso migliore e
|
||||
**4–6** nel peggiore, indipendentemente da `draw_animation_seconds`.
|
||||
I blocchi PLM arrivano in media ogni 120 secondi, ma l'intervallo fra due
|
||||
blocchi non ha un limite superiore: non esiste un "caso peggiore". A valori
|
||||
medi, dalla conferma dell'ultima scommessa servono 3 blocchi per l'estrazione
|
||||
e 1 per il pagamento, cioè 4 blocchi (8 minuti in media), indipendentemente
|
||||
da `draw_animation_seconds`. I depositi richiedono a parte più conferme prima
|
||||
di poter essere giocati
|
||||
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito) (B-82)).
|
||||
|
||||
## Cosa vede il giocatore
|
||||
|
||||
@@ -336,9 +638,7 @@ Due livelli indipendenti:
|
||||
|
||||
## Limite noto
|
||||
|
||||
- **[RND-32]** Se il server si riavvia mentre un round è in `drawing`,
|
||||
l'attesa del blocco **non riprende da sola**: lo scheduler riprende i round
|
||||
in `closing` e `paying_out`, ma su un round in `drawing` non fa nulla. È
|
||||
l'ultimo prerequisito mancante per un funzionamento completamente non
|
||||
presidiato (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`
|
||||
- ~~[RND-32]~~ Assorbito da [RND-36]: nel comportamento atteso un round in
|
||||
`drawing` riprende da solo dopo un riavvio o un errore. Finché B-81 non è
|
||||
implementato il limite resta: lo scheduler riprende i round in `closing` e
|
||||
`paying_out`, ma su un round in `drawing` non fa nulla.
|
||||
+52
-11
@@ -18,12 +18,22 @@ sezione rimanda al documento che la approfondisce.
|
||||
| [operativita.md](operativita.md) | Backup, riavvii, procedure per gli incidenti |
|
||||
| [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto con le loro motivazioni, limiti noti accettati |
|
||||
| [storico-bug.md](storico-bug.md) | Tutti i bug B-nn trovati e corretti, con commit e test |
|
||||
| [piano-correzioni.md](piano-correzioni.md) | Decisioni della revisione del 2026-09-28 e ordine in cui implementare le correzioni ancora aperte |
|
||||
|
||||
Questi documenti sono la **specifica** del software: ogni regola ha un
|
||||
identificativo stabile (ad esempio `RND-11`) con il riferimento al codice che
|
||||
la implementa. Come si usano per modificare il software è spiegato in
|
||||
[processo-di-modifica.md](processo-di-modifica.md).
|
||||
|
||||
Dalla revisione del 2026-09-28 la specifica descrive il comportamento
|
||||
**atteso**, che in alcuni punti è più avanti del codice. I requisiti che il
|
||||
codice non soddisfa ancora sono marcati `*Stato:* da implementare (B-nn)` e
|
||||
dicono in una riga cosa fa il codice oggi (`git grep "da implementare"` li
|
||||
elenca tutti); anche questa panoramica descrive il comportamento atteso. Le
|
||||
decisioni prese e l'ordine di implementazione sono in
|
||||
[piano-correzioni.md](piano-correzioni.md), il dettaglio tecnico di ogni voce
|
||||
in [BUGS.md](../BUGS.md).
|
||||
|
||||
Per installazione e avvio vedi [setup.md](setup.md) e
|
||||
[running-the-server.md](running-the-server.md); per l'uso delle interfacce
|
||||
[guida-utente.md](guida-utente.md) e [guida-admin.md](guida-admin.md).
|
||||
@@ -32,8 +42,10 @@ Per installazione e avvio vedi [setup.md](setup.md) e
|
||||
|
||||
Gli utenti depositano PLM su un indirizzo personale custodito dal server,
|
||||
comprano un biglietto a costo fisso per il round in corso, e allo scadere del
|
||||
round l'hash del primo blocco successivo sceglie un vincitore, che riceve il
|
||||
70% del montepremi; il 30% va alla piattaforma.
|
||||
round un seme che combina un segreto su cui il server si è impegnato
|
||||
all'apertura, l'hash di un blocco successivo alla chiusura e l'elenco dei
|
||||
partecipanti sceglie un vincitore, che riceve il 70% del montepremi; il 30%
|
||||
va alla piattaforma.
|
||||
|
||||
## Scelte di fondo
|
||||
|
||||
@@ -43,14 +55,30 @@ round l'hash del primo blocco successivo sceglie un vincitore, che riceve il
|
||||
depositi, riceve le vincite e riceve il resto di ogni scommessa e prelievo.
|
||||
- **Nessun nodo completo.** Tutto l'accesso alla rete passa dal protocollo
|
||||
Electrum (vedi [connessione-electrum.md](connessione-electrum.md)).
|
||||
- **1 conferma** per ogni tipo di transazione: depositi, scommesse, pagamenti,
|
||||
prelievi.
|
||||
- **6 conferme per i depositi, 1 per tutto il resto.** Il deposito è l'unico
|
||||
ingresso che un utente può spendere due volte, perché scommesse, pagamenti
|
||||
e prelievi li firma solo il server; le uscite coinbase richiedono 120
|
||||
conferme (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
|
||||
- **Estrazione con commit-reveal.** All'apertura il server si impegna su un
|
||||
segreto pubblicandone l'impronta; alla chiusura si fissano elenco dei
|
||||
partecipanti e altezza del blocco; il segreto viene rivelato dopo
|
||||
l'estrazione. Chi produce i blocchi non può calcolare il vincitore e
|
||||
l'operatore non può cambiare il segreto; chiunque può verificare il
|
||||
risultato con la prova pubblica (vedi
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
|
||||
- **Un indirizzo del montepremi per ogni round**, così soldi e cronologia di
|
||||
un round restano separati da quelli degli altri.
|
||||
- **Nel dubbio non si liberano i fondi.** Una transazione viene annullata solo
|
||||
con una prova, mai per un timeout o per il silenzio di un solo server (vedi
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)).
|
||||
- **Parametri operativi nel database**, modificabili a caldo da `/admin`
|
||||
(costo della scommessa, durata del round, commissioni…). Nel file `.env`
|
||||
restano solo segreti e collegamenti infrastrutturali.
|
||||
|
||||
## Le cinque fasi
|
||||
|
||||
Diagramma del **comportamento atteso**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph REG["1 · Registrazione"]
|
||||
@@ -60,7 +88,7 @@ flowchart LR
|
||||
|
||||
subgraph DEP["2 · Deposito"]
|
||||
direction TB
|
||||
B1["L'utente invia PLM<br/>al proprio indirizzo"] --> B2{"1 conferma e<br/>corroborato dagli<br/>altri server?"}
|
||||
B1["L'utente invia PLM<br/>al proprio indirizzo"] --> B2{"6 conferme e<br/>corroborato dagli<br/>altri server?"}
|
||||
B2 -- "No" --> B1b["Si riprova al prossimo<br/>aggiornamento"] --> B2
|
||||
B2 -- "Sì" --> B3["Saldo accreditato"]
|
||||
end
|
||||
@@ -68,7 +96,7 @@ flowchart LR
|
||||
subgraph PLAY["3 · Scommessa"]
|
||||
direction TB
|
||||
C1{"Saldo sufficiente,<br/>nessuna scommessa attiva,<br/>round aperto e non pieno?"}
|
||||
C1 -- "Sì" --> C2["Transazione: indirizzo utente →<br/>montepremi, resto all'utente"]
|
||||
C1 -- "Sì" --> C2["Transazione: indirizzo utente →<br/>montepremi del round, resto all'utente"]
|
||||
C2 --> C3{"Confermata?"}
|
||||
C3 -- "No, troppo tempo" --> C4["Commissione aumentata<br/>(RBF) e reinvio"] --> C3
|
||||
C3 -- "Sì" --> C5["Partecipante del round"]
|
||||
@@ -76,9 +104,14 @@ flowchart LR
|
||||
|
||||
subgraph DRAW["4 · Round ed estrazione"]
|
||||
direction TB
|
||||
D1["Scadenza del round"] --> D2["Si attendono le scommesse<br/>già in volo"]
|
||||
D2 --> D3["Il primo blocco successivo<br/>sceglie il vincitore"]
|
||||
D3 --> D4["70% al vincitore,<br/>30% alla piattaforma"]
|
||||
D0["Apertura: indirizzo del montepremi<br/>del round, impronta del segreto pubblicata"] --> D1["Scadenza del round"]
|
||||
D1 --> D2["Si attendono le scommesse<br/>già in volo"]
|
||||
D2 --> D3["Si fissano partecipanti<br/>e altezza del blocco"]
|
||||
D3 --> D3b["Blocco con 3 conferme, header<br/>concordato dal quorum:<br/>segreto + blocco + partecipanti<br/>scelgono il vincitore"]
|
||||
D3b --> D3c["Segreto rivelato,<br/>prova pubblica"]
|
||||
D3c --> D3d{"Scommesse ancora<br/>sulla catena?"}
|
||||
D3d -- "Sì (quelle sparite<br/>sono escluse)" --> D4["70% al vincitore, 30% alla piattaforma<br/>meno la riserva per l'RBF"]
|
||||
D3d -- "Sparita quella<br/>del vincitore" --> D5["Pagamento bloccato,<br/>lotteria in pausa"]
|
||||
end
|
||||
|
||||
subgraph WITHDRAW["5 · Prelievo"]
|
||||
@@ -139,7 +172,7 @@ solo polling.
|
||||
|
||||
| Parametro | Valore predefinito | Modificabile |
|
||||
|---|---|---|
|
||||
| Costo della scommessa | 10 PLM | da `/admin` |
|
||||
| Costo della scommessa | 10 PLM | da `/admin`, vale dal round successivo (B-95) |
|
||||
| Ripartizione del montepremi | 70% vincitore / 30% piattaforma | no, è nel codice |
|
||||
| Durata del round | 600s | da `/admin`, vale dal round successivo |
|
||||
| Pausa tra un round e l'altro | 30s | da `/admin`, vale dal round successivo |
|
||||
@@ -148,4 +181,12 @@ solo polling.
|
||||
| Prelievo minimo | pari al costo della scommessa | segue il costo |
|
||||
| Deposito minimo | nessuno | — |
|
||||
| Partecipanti massimi per round | 400 | no, è nel codice |
|
||||
| Conferme richieste | 1 | no, è nel codice |
|
||||
| Tetto al montepremi del round (`max_round_pool_sats`) | 0 = nessun tetto (B-95) | da `/admin`, vale dal round successivo |
|
||||
| Conferme per i depositi (`deposit_confirmations`) | 6; 120 per le uscite coinbase (B-82) | da `/admin`; le 120 coinbase no, sono nel codice |
|
||||
| Conferme sul blocco di estrazione (`draw_confirmations`) | 3 (B-73) | da `/admin`, vale dal round successivo |
|
||||
| Conferme per scommesse, pagamenti, prelievi | 1 | no, è nel codice |
|
||||
| Indirizzo del montepremi | uno per round, `m/84'/746'/0'/2/<id_round>` (B-80) | no, è nel codice |
|
||||
| Riserva per l'RBF sul pagamento | `max(294 sat, dimensione stimata × 10 sat/vB)`, tolta dalla quota della piattaforma, verso `m/84'/746'/0'/1/0` (B-79) | no, è nel codice |
|
||||
|
||||
Un B-nn nella tabella indica un parametro del comportamento atteso non ancora
|
||||
implementato (vedi [piano-correzioni.md](piano-correzioni.md)).
|
||||
+267
-60
@@ -11,6 +11,9 @@ I requisiti usano il prefisso `ELC`, secondo la convenzione di
|
||||
[processo-di-modifica.md](processo-di-modifica.md). La configurazione
|
||||
(`.env`) è descritta in [setup.md](setup.md), i rischi accettati in
|
||||
[sicurezza.md](sicurezza.md) e [decisioni-e-limiti.md](decisioni-e-limiti.md).
|
||||
Questo documento descrive il comportamento **atteso** dopo la revisione del
|
||||
2026-09-28; i requisiti che il codice non soddisfa ancora sono marcati
|
||||
*da implementare* (vedi [piano-correzioni.md](piano-correzioni.md)).
|
||||
|
||||
Le difese sono cinque.
|
||||
|
||||
@@ -18,16 +21,21 @@ Le difese sono cinque.
|
||||
|
||||
`ELECTRUM_HOST`/`ELECTRUM_PORT` è il server principale;
|
||||
`ELECTRUM_FALLBACK_SERVERS` è un elenco di riserva nel formato
|
||||
`host:porta[:notls]`, separato da virgole.
|
||||
`host:porta[:<modo>]`, separato da virgole.
|
||||
|
||||
- **[ELC-01]** L'elenco dei server è il principale (con TLS secondo
|
||||
`ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è
|
||||
`host:porta` (TLS) oppure `host:porta:<modo>`, con `<modo>` tra `ssl`/`tls`
|
||||
(TLS) e `notls`/`plain` (in chiaro); le voci vuote sono ignorate e i
|
||||
duplicati eliminati mantenendo la prima posizione. Una voce malformata
|
||||
blocca l'avvio subito, non durante il guasto in cui servirebbe.
|
||||
`host:porta` (TLS), `host:porta:<modo>` con `<modo>` tra `ssl`/`tls` (TLS) e
|
||||
`notls`/`plain` (in chiaro), oppure `host:porta:pin=<sha256>` (TLS con
|
||||
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali. Le
|
||||
voci vuote sono ignorate e i duplicati eliminati mantenendo la prima
|
||||
posizione. Una voce malformata blocca l'avvio subito, non durante il guasto
|
||||
in cui servirebbe.
|
||||
*Codice:* `app/electrum/client.py:parse_endpoints` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-84), per la forma `pin=` · *Oggi:* il terzo
|
||||
campo accetta solo `ssl`/`tls`/`notls`/`plain`; `pin=…` è una voce
|
||||
malformata.
|
||||
- **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il
|
||||
server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s
|
||||
raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una
|
||||
@@ -35,12 +43,29 @@ Le difese sono cinque.
|
||||
alcun server configurato il listener si ferma con un errore nei log.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-03]** Con TLS la connessione è cifrata ma il certificato del server
|
||||
**non viene verificato** (i server Electrum usano spesso certificati
|
||||
autofirmati): la fiducia non viene dal TLS ma dal quorum
|
||||
([§ 5](#5-il-quorum)). Ogni connessione si apre con la stretta di mano
|
||||
`server.version`.
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.connect`
|
||||
- **[ELC-03]** Con TLS la connessione è cifrata. Il certificato non è
|
||||
verificato contro un'autorità (i server Electrum usano spesso certificati
|
||||
autofirmati), ma può essere **fissato**: per una voce con `pin=<sha256>`
|
||||
([ELC-01]), e per il server principale con `ELECTRUM_CERT_PIN`, lo SHA-256
|
||||
del certificato del server in formato DER deve coincidere con il valore
|
||||
configurato, altrimenti la connessione fallisce come un server
|
||||
irraggiungibile e si passa al successivo ([ELC-02]). Senza pin la fiducia
|
||||
non viene dal TLS ma dal quorum ([§ 5](#5-il-quorum)). Ogni connessione si
|
||||
apre con la stretta di mano `server.version`.
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.connect` · *Test:* —
|
||||
*Stato:* da implementare (B-84) · *Oggi:* nessun certificato viene mai
|
||||
verificato; non esiste un modo di fissarlo.
|
||||
- **[ELC-19]** Il server **rifiuta di avviarsi** se, dopo l'eliminazione dei
|
||||
duplicati, l'elenco contiene meno di **2 server di riserva** oltre al
|
||||
principale: con meno di tre server il quorum di [ELC-14] non può mai avere
|
||||
due risposte indipendenti da quella del server attivo. L'unica eccezione è
|
||||
`ALLOW_NO_QUORUM=true`, **solo per lo sviluppo**: il server parte, scrive un
|
||||
avviso nei log a ogni avvio, e le corroborazioni seguono la regola ridotta
|
||||
di [ELC-14].
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* —
|
||||
*Stato:* da implementare (B-84) · *Oggi:* un `ELECTRUM_FALLBACK_SERVERS`
|
||||
vuoto (il valore di `.env.example`) è accettato in silenzio e disattiva
|
||||
ogni corroborazione.
|
||||
|
||||
## 2. Richieste con limite di tempo
|
||||
|
||||
@@ -53,6 +78,19 @@ Le difese sono cinque.
|
||||
conferme.
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.request`, `app/electrum/client.py:ElectrumClient._read_loop` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
- **[ELC-20]** Una riga di risposta può essere lunga fino a **16 MiB**
|
||||
(16 777 216 byte). Una riga più lunga, o una riga che non è JSON valido,
|
||||
è un errore **della richiesta**, non della connessione: la riga viene
|
||||
scartata fino al suo a capo e la connessione resta aperta. La richiesta a
|
||||
cui rispondeva fallisce subito con un errore se il suo `id` si legge nei
|
||||
primi o negli ultimi 256 byte della riga; altrimenti fallisce allo scadere
|
||||
di [ELC-04].
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._read_loop` · *Test:* —
|
||||
*Stato:* da implementare (B-75) · *Oggi:* il lettore ha il limite
|
||||
predefinito di asyncio (64 KiB); una riga più lunga, o non JSON, interrompe
|
||||
`_read_loop` e chiude l'unica connessione. La cronologia dell'indirizzo del
|
||||
montepremi supera 64 KiB dopo 800 transazioni (stima di B-75), e da lì ogni controllo
|
||||
delle conferme la fa cadere.
|
||||
|
||||
## 3. Caduta rilevata
|
||||
|
||||
@@ -61,12 +99,18 @@ Le difese sono cinque.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive`
|
||||
- **[ELC-06]** La chiusura della connessione è un evento osservato: la
|
||||
sessione termina non appena la connessione si chiude, il ping fallisce o
|
||||
l'elaborazione degli header o delle notifiche degli indirizzi si interrompe
|
||||
con un errore. Il listener non resta quindi in attesa di notifiche che non
|
||||
arriveranno mai mentre sembra ancora connesso; a sessione finita il client
|
||||
non è più disponibile.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed` ·
|
||||
l'elaborazione degli header si interrompe con un errore. Il listener non
|
||||
resta quindi in attesa di notifiche che non arriveranno mai mentre sembra
|
||||
ancora connesso; a sessione finita il client non è più disponibile. Un
|
||||
errore nell'aggiornamento di **un** utente dopo una notifica del suo
|
||||
indirizzo non termina la sessione
|
||||
([DEP-16](registrazione-e-depositi.md#deposito)).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
|
||||
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
|
||||
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
|
||||
aggiornamenti concorrenti) termina la sessione.
|
||||
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
|
||||
**solo dopo** che il primo header è stato applicato: se il client c'è,
|
||||
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
|
||||
@@ -76,81 +120,229 @@ Le difese sono cinque.
|
||||
scommesse e prelievi rispondono `network_unavailable`.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-21]** **Server fermo.** Un server può restare connesso e rispondere
|
||||
ai ping senza più seguire la catena. A ogni ping di [ELC-05], se l'ultimo
|
||||
header accettato ha più di **360 secondi**, il listener chiede la punta
|
||||
(`blockchain.headers.subscribe`) a ciascuno degli altri server configurati,
|
||||
con i tempi di [ELC-14]; se almeno uno riporta un'altezza **maggiore** di
|
||||
quella nota, la sessione termina e si passa al server successivo
|
||||
nell'elenco ([ELC-02]). Se nessuno riporta un'altezza maggiore (la rete
|
||||
non ha prodotto blocchi) la sessione continua.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive` · *Test:* —
|
||||
*Stato:* da implementare (B-86) · *Oggi:* nulla confronta l'avanzamento
|
||||
della punta con gli altri server; un server fermo che risponde ai ping
|
||||
resta il server attivo indefinitamente.
|
||||
|
||||
## 4. Header validati, non creduti
|
||||
|
||||
Ogni nuovo header di blocco — quello ricevuto all'iscrizione e ogni successivo
|
||||
annunciato dal server — viene controllato prima di essere accettato.
|
||||
annunciato dal server — viene controllato prima di essere accettato. Il
|
||||
listener tiene gli **ultimi 10 header** accettati ([ELC-22]): è ciò che gli
|
||||
permette di riconoscere una riorganizzazione e seguirla invece di scambiarla
|
||||
per un server ostile.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Header annunciato<br/>all'altezza H"] --> B{"Ha il contenuto<br/>binario?"}
|
||||
B -- "No" --> I1["Ignorato (ELC-10)"]
|
||||
B -- "Sì" --> C{"H rispetto alla<br/>punta T"}
|
||||
C -- "H < T" --> I2["Ignorato (ELC-08)"]
|
||||
C -- "H = T, stesso hash" --> I3["Ignorato (ELC-09)"]
|
||||
C -- "H = T, hash diverso" --> F
|
||||
C -- "H > T" --> D["Si scaricano gli header<br/>T+1 … H−1 (ELC-12)"]
|
||||
D --> E{"80 byte, bits non più<br/>facili del powLimit, hash<br/>entro il bersaglio? (ELC-11)"}
|
||||
E -- "No" --> X["HeaderValidationError:<br/>sessione chiusa,<br/>server successivo"]
|
||||
E -- "Sì" --> G{"T+1 si aggancia<br/>alla punta T?"}
|
||||
G -- "Sì, e la catena<br/>è collegata fino a H" --> OK["Punta = H<br/>finestra aggiornata (ELC-13)"]
|
||||
G -- "No" --> F["Ricerca del punto di<br/>biforcazione negli ultimi<br/>10 header (ELC-23)"]
|
||||
F -- "Trovato" --> R["Si segue il nuovo ramo<br/>chain_reorg_detected;<br/>pausa se profondità ≥ 2 (ELC-24)"]
|
||||
F -- "Non trovato" --> X
|
||||
```
|
||||
|
||||
*Comportamento atteso.* Oggi un salto di più blocchi non viene collegato, la
|
||||
difficoltà non è confrontata con il powLimit, e un header che non si aggancia
|
||||
chiude la sessione.
|
||||
|
||||
Tre casi vengono semplicemente **ignorati**, con un avviso nei log e senza
|
||||
chiudere la sessione, perché non indicano un server ostile:
|
||||
|
||||
- **[ELC-08]** un header per un'altezza **inferiore** a quella nota: l'altezza
|
||||
non torna mai indietro;
|
||||
- **[ELC-09]** un header per l'altezza **già nota** (lo stesso blocco
|
||||
riannunciato, una riorganizzazione in punta, o un server in disaccordo):
|
||||
l'hash già adottato per quell'altezza non viene mai sostituito; se fosse
|
||||
quello sbagliato, è la corroborazione dell'estrazione a scartarlo;
|
||||
non torna mai indietro. Un server rimasto indietro non sposta la punta, e un
|
||||
ramo più corto del nostro non viene seguito. Il rovescio va detto: se un
|
||||
header falso a un'altezza molto alta venisse accettato, nessun header
|
||||
onesto (più basso) lo sostituirebbe più; la punta resta bloccata fino al
|
||||
riavvio del processo (l'altezza è solo in memoria). Con [ELC-11] e
|
||||
[ELC-12] quell'header deve portarsi dietro una catena collegata di header
|
||||
con lavoro valido; e comunque non sceglie il vincitore, perché il blocco di
|
||||
estrazione è letto a un'altezza fissa e confermato dal quorum ([ELC-15]):
|
||||
l'estrazione attende, e l'attesa è visibile (`draw_stalled`,
|
||||
`GET /health/ready`, [ELC-29]).
|
||||
- **[ELC-09]** un header per l'altezza **già nota** con lo **stesso** hash
|
||||
(lo stesso blocco riannunciato). Un header per l'altezza già nota con un
|
||||
hash **diverso** non è ignorato: è una riorganizzazione in punta e si
|
||||
segue secondo [ELC-23].
|
||||
*Stato:* da implementare (B-85), per il caso con hash diverso · *Oggi:*
|
||||
anche un hash diverso è ignorato e quello già adottato non viene mai
|
||||
sostituito.
|
||||
- **[ELC-10]** un header privo del contenuto binario: non c'è nulla da
|
||||
validare né da usare per l'estrazione.
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
|
||||
Due controlli invece, se falliscono, **chiudono la sessione** e fanno passare
|
||||
a un altro server: quell'header è l'unica fonte di casualità dell'estrazione,
|
||||
e uno falsificato sceglierebbe il vincitore.
|
||||
I controlli seguenti invece, se falliscono, **chiudono la sessione** e fanno
|
||||
passare a un altro server.
|
||||
|
||||
- **[ELC-11]** L'header deve essere lungo 80 byte e il suo hash deve
|
||||
rispettare la difficoltà che l'header stesso dichiara (campo `bits`).
|
||||
- **[ELC-11]** L'header deve essere lungo 80 byte; il bersaglio che dichiara
|
||||
(campo `bits`) non deve essere più facile del **powLimit** di PLM mainnet,
|
||||
`00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff`
|
||||
(in forma compatta `0x1d00ffff`, lo stesso di Bitcoin; fonte:
|
||||
`palladiumcore/src/chainparams.cpp`, `consensus.powLimit` della rete
|
||||
principale); e il suo hash deve rispettare quel bersaglio. Il controllo
|
||||
toglie la contraffazione **gratuita**, non quella economica: anche al
|
||||
powLimit un header costa in media 2³² hash, 43 µs per un ASIC da
|
||||
100 TH/s. Ciò che protegge davvero l'estrazione è il quorum sul blocco
|
||||
([ELC-15]).
|
||||
*Codice:* `app/rounds/draw.py:header_meets_its_own_target`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||||
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-12]** Un avanzamento di **un solo** blocco deve agganciarsi all'hash
|
||||
del blocco corrente. Per un salto di più blocchi questo controllo non è
|
||||
possibile senza la catena intera, e vale solo quello di difficoltà.
|
||||
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||||
*Stato:* da implementare (B-74), per il confronto con il powLimit ·
|
||||
*Oggi:* si verifica solo che l'hash rispetti il bersaglio che l'header
|
||||
stesso dichiara. Poiché è chi fabbrica l'header a scegliere `bits`, il
|
||||
controllo non respinge un header mai minato: con `bits = 0x207fffff`
|
||||
(bersaglio `0x7fffff · 2²³²`, appena sotto 2²⁵⁵) lo passa un header casuale con probabilità 1/2.
|
||||
- **[ELC-12]** **Ogni** avanzamento della punta deve essere collegato alla
|
||||
punta corrente. Un avanzamento di un blocco deve avere come `prev_hash`
|
||||
l'hash della punta. Per un avanzamento di più blocchi, dalla punta `T` a
|
||||
`H`, si scaricano dal server attivo gli header `T+1 … H−1`
|
||||
(`blockchain.block.headers`, a blocchi di al più 2016 header per
|
||||
richiesta); ciascuno deve superare [ELC-11] e agganciarsi al precedente, e
|
||||
`H` all'ultimo. Un header scaricato che non supera [ELC-11], o una catena
|
||||
scaricata spezzata, chiude la sessione. Se invece è il **primo** passo
|
||||
(`T+1`, o `H` stesso per un avanzamento di un blocco) a non agganciarsi
|
||||
alla punta, non si chiude la sessione: è una possibile riorganizzazione e
|
||||
si segue [ELC-23].
|
||||
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/client.py:ElectrumClient.request` ·
|
||||
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-74) · *Oggi:* il collegamento si verifica solo
|
||||
per un avanzamento di un blocco; un salto di più blocchi è accettato con il
|
||||
solo controllo di difficoltà. Un avanzamento di un blocco che non si
|
||||
aggancia chiude la sessione: per questo una normale riorganizzazione di un
|
||||
blocco (la nostra punta `N` diventa orfana) fa chiudere la sessione su
|
||||
**ogni** server — ciascuno annuncia `N+1` sul nuovo ramo, che non si
|
||||
aggancia alla nostra `N` — finché l'arrivo di `N+2` non rende l'avanzamento
|
||||
un salto di due blocchi, accettato senza controllo di collegamento.
|
||||
- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della
|
||||
punta (mai uno senza l'altro). Ogni notifica di nuovi header invia una
|
||||
notifica SSE, perché è proprio ciò che l'estrazione attende.
|
||||
punta (mai uno senza l'altro) e la finestra di [ELC-22]. Ogni notifica di
|
||||
nuovi header invia una notifica SSE, perché è proprio ciò che l'estrazione
|
||||
attende.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._consume_headers` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-22]** Il listener tiene in memoria gli **ultimi 10 header**
|
||||
accettati (altezza → contenuto), punta compresa. Il primo header del
|
||||
processo non ha nulla a cui agganciarsi ed è accettato con il solo
|
||||
[ELC-11]; subito dopo si scaricano i 9 header precedenti, che devono
|
||||
superare [ELC-11] ed essere collegati fra loro e al primo, altrimenti la
|
||||
sessione si chiude. La finestra sopravvive alle riconnessioni, non ai
|
||||
riavvii.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._run_once` · *Test:* —
|
||||
*Stato:* da implementare (B-85) · *Oggi:* si tiene solo la punta
|
||||
(`tip_height`, `tip_header_hex`).
|
||||
|
||||
Il controllo di difficoltà respinge un header mai minato, ma non uno minato
|
||||
davvero a una difficoltà dichiarata irrealisticamente bassa: per questo il
|
||||
blocco dell'estrazione viene anche corroborato ([ELC-15]).
|
||||
### Riorganizzazioni
|
||||
|
||||
- **[ELC-23]** Quando un header non si aggancia alla punta ([ELC-12]) o ne
|
||||
sostituisce l'hash alla stessa altezza ([ELC-09]), il listener cerca il
|
||||
**punto di biforcazione**: chiede al server attivo gli header delle altezze
|
||||
della finestra, dalla punta verso il basso, fino a 10, e prende la più alta
|
||||
altezza in cui l'hash del server coincide con il nostro. Se lo trova, il
|
||||
ramo del server dal punto di biforcazione fino al nuovo header deve
|
||||
superare [ELC-11] ed essere collegato; allora sostituisce la finestra sopra
|
||||
il punto di biforcazione e il nuovo header diventa la punta. Se non lo
|
||||
trova entro 10 header (una biforcazione più profonda, o un server su
|
||||
un'altra catena), la sessione si chiude come per [ELC-11].
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · *Test:* —
|
||||
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessuna riorganizzazione è
|
||||
rilevata né seguita; vedi l'*Oggi* di [ELC-12].
|
||||
- **[ELC-24]** Ogni riorganizzazione seguita scrive l'evento
|
||||
`chain_reorg_detected` con la profondità (numero dei nostri blocchi
|
||||
sostituiti, cioè vecchia altezza della punta meno altezza del punto di
|
||||
biforcazione), l'altezza del punto di biforcazione, la vecchia e la nuova
|
||||
altezza della punta e i due hash di punta. Con profondità **≥ 2** la
|
||||
lotteria va in pausa (`paused = true`, `paused_reason = 'reorg'`, vedi
|
||||
[modello-dati.md](modello-dati.md) B-85) finché l'operatore non la
|
||||
riprende: come ogni pausa, impedisce solo l'apertura del round successivo.
|
||||
Con profondità 1 si scrive solo l'evento.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/db/models.py:RoundConfig` · *Test:* —
|
||||
*Stato:* da implementare (B-85) · *Oggi:* l'evento non esiste e nulla mette
|
||||
in pausa la lotteria da solo.
|
||||
|
||||
## 5. Il quorum
|
||||
|
||||
Ogni decisione che sposta denaro viene confermata dagli **altri** server
|
||||
configurati, mai da quello attivo (che è proprio quello che un attaccante
|
||||
controllerebbe).
|
||||
Ogni decisione che sposta denaro in base a una risposta della rete viene
|
||||
confermata dagli **altri** server configurati, mai dal solo server attivo
|
||||
(che è proprio quello che un attaccante controllerebbe).
|
||||
|
||||
Oggi non è ancora così: solo le tre verifiche [ELC-15]–[ELC-17] sono
|
||||
corroborate. Le conferme delle transazioni (`app/tx/confirmation.py:poll_once`),
|
||||
l'abbandono di una transazione (`app/tx/reconcile.py:reconcile_once`), il
|
||||
reintegro di una moneta ricomparsa
|
||||
(`app/deposits/service.py:reinstate_reappeared_utxos`) e la lettura delle
|
||||
monete del montepremi per il pagamento
|
||||
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basano sul solo
|
||||
server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27].
|
||||
|
||||
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
|
||||
configurati tranne quello attivo, ciascuno con una connessione nuova e un
|
||||
limite di 10 secondi (`_CORROBORATION_TIMEOUT_SECONDS`) per collegarsi e 10
|
||||
per rispondere. Passa se concorda la **maggioranza stretta dei server che
|
||||
hanno risposto**; un server che non risponde o dà errore non conta.
|
||||
- **Nessun server di riserva configurato** → la verifica passa. È il rischio
|
||||
accettato di un `ELECTRUM_FALLBACK_SERVERS` vuoto: in produzione conviene
|
||||
configurarne più d'uno (vedi [setup.md](setup.md)).
|
||||
- **Nessuno risponde** → la verifica **fallisce**: una rete irraggiungibile
|
||||
non prova nulla.
|
||||
|
||||
per rispondere. Un server che non risponde o dà errore non conta. Passa se
|
||||
hanno risposto **almeno 2** server **e** concorda la **maggioranza stretta
|
||||
di quelli che hanno risposto**; con meno di 2 risposte **fallisce**: una
|
||||
rete irraggiungibile, o un solo server raggiungibile, non prova nulla.
|
||||
Con `ALLOW_NO_QUORUM=true` ([ELC-19]) vale invece la regola ridotta: senza
|
||||
altri server configurati la verifica passa, altrimenti basta la
|
||||
maggioranza stretta delle risposte, anche di una sola.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* vale per tutti la regola ridotta.
|
||||
Senza server di riserva ogni verifica passa; con server di riserva la
|
||||
maggioranza è calcolata su chi ha risposto, quindi **una risposta su una
|
||||
decide**: se è raggiungibile un solo altro server, la sua parola basta.
|
||||
Nessuna risposta fa fallire la verifica.
|
||||
|
||||
| ID | Prima di… | Si verifica che… |
|
||||
|---|---|---|
|
||||
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che l'altro server restituisce per quell'altezza (`blockchain.block.header`) abbia lo stesso hash |
|
||||
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che ciascun server restituisce per l'altezza di estrazione (`blockchain.block.header`) abbia lo stesso hash; regole complete in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione) (B-73) |
|
||||
| **[ELC-16]** | togliere dal saldo una moneta sparita | l'altro server non elenchi più la moneta tra quelle non spese |
|
||||
| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0) |
|
||||
| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo, a un'altezza che le dà almeno le conferme richieste ([DEP-03](registrazione-e-depositi.md#deposito)) contate sulla nostra punta |
|
||||
| **[ELC-25]** | reintegrare una moneta data per spesa fuori | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0), come per un accredito |
|
||||
| **[ELC-26]** | segnare confermata una transazione | l'altro server restituisca per il txid una prova merkle (`blockchain.transaction.get_merkle`) che, ricalcolata, dà la radice merkle dell'header di quell'altezza; l'header è quello della finestra ([ELC-22]) o, se più vecchio, quello su cui concorda il quorum come in [ELC-15]. Regole della conferma in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#conferma) (B-83) |
|
||||
| **[ELC-27]** | costruire il pagamento di un round | l'altro server elenchi come non spese le monete delle scommesse del round all'indirizzo del suo montepremi; regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) (B-82) |
|
||||
| **[ELC-28]** | abbandonare una transazione | l'altro server riporti un suo ingresso speso in un blocco da un txid diverso, oppure non conosca nessuno dei suoi txid; regole in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione) (B-78) |
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`,
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
|
||||
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
|
||||
Stato delle righe della tabella:
|
||||
|
||||
- [ELC-15] — *Stato:* da implementare (B-73) · *Oggi:* si corrobora l'header
|
||||
del primo blocco che il server attivo ha annunciato dopo la chiusura,
|
||||
chiedendo solo agli altri server; se la verifica fallisce si passa al blocco
|
||||
successivo invece di richiedere la stessa altezza.
|
||||
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
|
||||
maggiore di 0 (1 conferma).
|
||||
- [ELC-25] — *Stato:* da implementare (B-83) · *Oggi:* il reintegro si basa
|
||||
sulla sola risposta del server attivo.
|
||||
- [ELC-26] — *Stato:* da implementare (B-83) · *Oggi:* una transazione è
|
||||
confermata quando la cronologia dell'indirizzo sul server attivo la riporta
|
||||
con altezza maggiore di 0.
|
||||
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
|
||||
fra le monete che il server attivo elenca per il montepremi.
|
||||
- [ELC-28] — *Stato:* da implementare (B-78) · *Oggi:* si abbandona quando la
|
||||
cronologia dell'indirizzo sul server attivo non contiene il txid.
|
||||
|
||||
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
|
||||
stesso quorum. Le conseguenze di una verifica non riuscita:
|
||||
|
||||
@@ -158,14 +350,19 @@ stesso quorum. Le conseguenze di una verifica non riuscita:
|
||||
riproposta al controllo successivo
|
||||
([DEP-04](registrazione-e-depositi.md#deposito));
|
||||
- una moneta sparita non verificata resta nel saldo
|
||||
([DEP-07](registrazione-e-depositi.md#deposito));
|
||||
- un blocco non verificato fa attendere il blocco seguente, e lo si registra
|
||||
in audit (`draw_header_corroboration_failed`,
|
||||
[RND-16](ciclo-di-vita-del-round.md#3--estrazione)).
|
||||
([DEP-07](registrazione-e-depositi.md#deposito)), una moneta ricomparsa non
|
||||
verificata resta fuori ([DEP-08](registrazione-e-depositi.md#deposito));
|
||||
- una conferma non verificata lascia la transazione in attesa, e si riprova
|
||||
al controllo successivo;
|
||||
- un blocco di estrazione non verificato fa attendere e richiedere la stessa
|
||||
altezza, e lo si registra in audit (`draw_header_corroboration_failed`,
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
|
||||
|
||||
Il reintegro di una moneta ricomparsa
|
||||
([DEP-08](registrazione-e-depositi.md#deposito)) e la lettura delle monete del
|
||||
montepremi per il pagamento si basano invece sul solo server attivo.
|
||||
L'**invio** di una transazione non è una corroborazione ma usa gli stessi
|
||||
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
|
||||
inviata anche a ogni server di riserva, al meglio. La regola è in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-76,
|
||||
B-83); oggi l'invio va al solo server attivo.
|
||||
|
||||
## Riconnessione
|
||||
|
||||
@@ -181,6 +378,16 @@ montepremi per il pagamento si basano invece sul solo server attivo.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._subscribe_all_users`, `app/electrum/listener.py:ElectrumListener._run_once` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
|
||||
Lo stato della connessione e l'altezza corrente sono visibili nella barra di
|
||||
stato della catena di `/` (da `chain_tip_height` di `GET /rounds/current`) e
|
||||
nell'indicatore Electrum di `/admin`.
|
||||
## Stato della connessione
|
||||
|
||||
- **[ELC-29]** Il listener espone lo stato della connessione: se è connesso,
|
||||
il server attivo, l'altezza della punta e da quanti secondi è stato
|
||||
accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non
|
||||
connesso o se l'ultimo header ha più di 360 secondi,
|
||||
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
|
||||
corrente resta visibile anche nella barra di stato della catena di `/` (da
|
||||
`chain_tip_height` di `GET /rounds/current`).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
|
||||
*Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre
|
||||
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
|
||||
mostra solo l'altezza.
|
||||
+705
-183
File diff suppressed because it is too large.
Load diff
+667
-209
File diff suppressed because it is too large.
Load diff
+528
-134
@@ -14,6 +14,12 @@ esatto è in [audit-log.md](audit-log.md). Il funzionamento interno che spiega
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
|
||||
[connessione-electrum.md](connessione-electrum.md).
|
||||
|
||||
**Procedure e requisiti.** Le procedure (i passi numerati) descrivono cosa
|
||||
fare **con il codice di oggi**; dove una correzione in corso le cambierà, una
|
||||
nota "Dopo B-nn" dice come. I requisiti marcati *da implementare* descrivono
|
||||
invece il comportamento atteso, non ancora presente (vedi
|
||||
[processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
|
||||
|
||||
## Topologia
|
||||
|
||||
```mermaid
|
||||
@@ -38,18 +44,35 @@ flowchart LR
|
||||
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
|
||||
*Codice:* `docker-compose.yml`, `Caddyfile`
|
||||
|
||||
Oggi il container gira come root, e i file che scrive in `./data` (database,
|
||||
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
|
||||
gira come utente non root, con `./data` in `0700` e i file di chiave in
|
||||
`0600`, e la chiave di cifratura sta in un file a parte
|
||||
([architettura.md](architettura.md) ARC-03, ARC-05;
|
||||
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
|
||||
dovranno allora rispettare quel proprietario.
|
||||
|
||||
- **[OPS-02]** All'avvio il container `app` esegue, in quest'ordine: controllo
|
||||
che il file della master key esista (altrimenti esce con errore e un
|
||||
suggerimento del comando da lanciare), `alembic upgrade head`, avvio del
|
||||
server. Durante l'avvio dell'applicazione `validate_runtime_secrets` rifiuta
|
||||
di partire se `JWT_SECRET` è più corto di 32 caratteri o
|
||||
`XPRV_ENCRYPTION_KEY` è vuoto, e `parse_endpoints` rifiuta una voce malformata
|
||||
in `ELECTRUM_FALLBACK_SERVERS`. Un `ADMIN_TOKEN` vuoto non blocca l'avvio: il
|
||||
pannello resta chiuso a tutti.
|
||||
server. Durante l'avvio dell'applicazione il server rifiuta di servire se
|
||||
un segreto non è valido ([SEC-12](sicurezza.md#sec-12)), se la master key
|
||||
non si decifra o non corrisponde agli indirizzi già salvati
|
||||
([SEC-49](sicurezza.md#sec-49)), se una voce di
|
||||
`ELECTRUM_FALLBACK_SERVERS` è malformata (`parse_endpoints`) o se il quorum
|
||||
minimo non è configurato ([OPS-12]). Un `ADMIN_TOKEN` vuoto non blocca
|
||||
l'avvio: il pannello resta chiuso a tutti. Un rifiuto termina il processo;
|
||||
con `restart: unless-stopped` il container riparte e fallisce di nuovo,
|
||||
quindi il sintomo è un container che si riavvia in continuazione, con il
|
||||
motivo nell'output di `docker compose logs app`.
|
||||
*Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`,
|
||||
`app/config.py:validate_runtime_secrets`,
|
||||
`app/electrum/client.py:parse_endpoints` ·
|
||||
`app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` ·
|
||||
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-89, B-84) · *Oggi:* della master key si
|
||||
controlla solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota;
|
||||
una chiave sbagliata emerge alla prima derivazione, e il server parte anche
|
||||
senza server di riserva.
|
||||
|
||||
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
|
||||
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
|
||||
@@ -64,18 +87,34 @@ flowchart LR
|
||||
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
|
||||
*Codice:* `app/main.py:health`
|
||||
|
||||
Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
|
||||
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
|
||||
([api.md](api.md) API-46), ed è il `healthcheck` del container
|
||||
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
|
||||
viene riavviato da Docker: è un segnale per l'operatore
|
||||
([OPS-25]), da trattare con [OPS-11] o [OPS-13].
|
||||
|
||||
## Backup e ripristino
|
||||
|
||||
### Cosa salvare
|
||||
|
||||
| Cosa | Dove | Quanto spesso | Perché |
|
||||
|---|---|---|---|
|
||||
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
|
||||
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) e **prima di ogni deploy** | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
|
||||
| Master key cifrata | `./data/keys/master.xprv.enc` | Una volta (non cambia) | Da essa derivano tutti gli indirizzi e il montepremi |
|
||||
| `XPRV_ENCRYPTION_KEY` | `.env` | Una volta, **in un luogo separato** dalla master key | Senza, la master key cifrata è illeggibile |
|
||||
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum |
|
||||
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum, `SITE_ADDRESS` |
|
||||
| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano |
|
||||
|
||||
Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
|
||||
legge. Su questa installazione esiste anche una seconda copia,
|
||||
`./master.xprv.enc` nella radice del progetto, a cui punta il
|
||||
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
|
||||
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma
|
||||
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
|
||||
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
|
||||
oggi la variabile, separata dalla master key.
|
||||
|
||||
- **[OPS-05]** Master key cifrata e `XPRV_ENCRYPTION_KEY` si salvano **in posti
|
||||
diversi**: uno dei due da solo non basta a spendere, entrambi insieme danno il
|
||||
controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i
|
||||
@@ -96,43 +135,162 @@ flowchart LR
|
||||
Copiare a mano il solo `plm_lottery.db` mentre il server gira **non** è un
|
||||
backup affidabile ([OPS-03]). In alternativa: `docker compose stop app`,
|
||||
copiare i tre file, `docker compose start app`, ma quel riavvio ha le
|
||||
conseguenze descritte in [OPS-10].
|
||||
conseguenze descritte in [OPS-10]. Oggi il file di backup appartiene a root
|
||||
sull'host ([Topologia](#topologia)).
|
||||
*Codice:* `app/db/base.py:_register_sqlite_pragmas`
|
||||
|
||||
### Ripristino
|
||||
|
||||
Un database ripristinato è **indietro rispetto alla catena**: round già
|
||||
estratti e pagati risultano ancora aperti, scommesse e prelievi fatti dopo il
|
||||
backup non esistono, utenti registrati dopo il backup sono spariti. La
|
||||
procedura serve a impedire che il sistema *rigiochi* quel passato con il
|
||||
denaro di oggi.
|
||||
|
||||
- **[OPS-07]** Procedura di ripristino:
|
||||
1. `docker compose down`.
|
||||
1. `docker compose down`. Il pubblico non deve raggiungere l'app finché la
|
||||
procedura non è finita: `caddy` si riavvia solo al passo 10.
|
||||
2. Rimettere `.env` (con la stessa `XPRV_ENCRYPTION_KEY` usata per cifrare la
|
||||
master key) e `./data/keys/master.xprv.enc`. Se si ha solo la xprv in
|
||||
chiaro, ricifrarla con `scripts/encrypt_master_key.py`.
|
||||
chiaro, ricifrarla con `scripts/encrypt_master_key.py` lanciato nel
|
||||
container (vedi [OPS-22], passo 0, per il perché).
|
||||
3. Rimettere il database in `./data/db/plm_lottery.db` e **cancellare**
|
||||
eventuali `-wal`/`-shm` rimasti dall'istanza precedente, che non
|
||||
appartengono al file ripristinato.
|
||||
4. `docker compose up -d --build`. Le migrazioni si applicano da sole.
|
||||
5. Controllare in `/admin` lo stato del round attivo e le transazioni
|
||||
pendenti; un round rimasto in `drawing` nel backup va sbloccato con
|
||||
[OPS-13].
|
||||
4. **Mettere in pausa la lotteria nel database ripristinato, prima di
|
||||
avviare l'app** (il valore di `paused` è quello del backup):
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); c.execute('UPDATE round_config SET paused=1'); c.commit(); print('pausa impostata')"
|
||||
```
|
||||
La modifica non lascia una riga di audit: annotarla altrove.
|
||||
Dopo B-90 si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
|
||||
scrive anche il motivo della pausa e la riga di audit.
|
||||
5. **Elencare, sempre ad app ferma, i round non chiusi e le transazioni in
|
||||
volo** del backup:
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print('ROUND'); [print(r) for r in c.execute(\"SELECT id,status,winner_user_id,payout_txid FROM rounds WHERE status!='closed'\")]; print('PENDING'); [print(r) for r in c.execute(\"SELECT id,kind,status,round_id,current_txid FROM pending_transactions WHERE status IN ('building','pending')\")]"
|
||||
```
|
||||
e, per ogni round trovato, i partecipanti
|
||||
(`SELECT user_id,status,bet_txid FROM round_participants WHERE round_id=<ID>`,
|
||||
con lo stesso comando).
|
||||
6. **Confrontare ogni round non chiuso con la catena**, con un explorer,
|
||||
**prima** di avviare l'app. È necessario perché la pausa ferma solo
|
||||
l'apertura di round nuovi: appena c'è connessione, un round del backup in
|
||||
`open`, `closing` o `paying_out` riprende da solo, chiude, estrae e paga
|
||||
([OPS-09]). Oggi tutti i round usano lo stesso indirizzo del montepremi
|
||||
(`derive_pool_address`, vedi [OPS-15]), quindi un pagamento rifatto
|
||||
spenderebbe il denaro dei round successivi. Per ogni round:
|
||||
- si guardano le uscite delle sue scommesse verso l'indirizzo del
|
||||
montepremi (i `bet_txid` dei partecipanti) e, se c'è, `payout_txid`;
|
||||
- **se una di quelle uscite è già spesa, o `payout_txid` è confermato**,
|
||||
il round è già stato pagato dopo il backup. Va chiuso a mano, e ogni
|
||||
sua riga `payout` ancora `building`/`pending` va segnata `failed`,
|
||||
altrimenti il riconciliatore la abbandonerebbe e lo scheduler
|
||||
ricostruirebbe il pagamento:
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print(c.execute(\"UPDATE rounds SET status='closed', closed_at=CURRENT_TIMESTAMP WHERE id=<ID> AND status!='closed'\").rowcount, 'round'); print(c.execute(\"UPDATE pending_transactions SET status='failed', failure_reason='restore: round already paid' WHERE kind='payout' AND round_id=<ID> AND status IN ('building','pending')\").rowcount, 'payout'); c.commit()"
|
||||
```
|
||||
Unica eccezione: se `payout_txid` della riga `pending` è proprio la
|
||||
transazione confermata, non serve toccare nulla, perché il
|
||||
`ConfirmationPoller` chiude il round da sé;
|
||||
- **se tutte le uscite sono ancora non spese**, il round non è stato
|
||||
pagato e può riprendere. I giocatori che hanno scommesso su quel round
|
||||
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
|
||||
montepremi: annotarli per un rimborso manuale (nessuna procedura
|
||||
supportata, vedi [OPS-24]);
|
||||
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
|
||||
questo controllo: se è già stato pagato, va chiuso come sopra.
|
||||
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
|
||||
audit.
|
||||
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
|
||||
ferma.
|
||||
8. Avviare **solo** l'app: `docker compose up -d --build app` (il servizio
|
||||
`caddy` resta fermo). Le migrazioni si applicano da sole.
|
||||
9. In `data/logs/app.log` aspettare `Electrum connected to …`. Alla
|
||||
connessione il listener risottoscrive e aggiorna subito ogni utente
|
||||
([ELC-18](connessione-electrum.md#riconnessione)), senza aspettare il giro
|
||||
di 300 secondi di `DepositReconciler`: gli UTXO spesi dopo il backup
|
||||
(scommesse e prelievi persi dal database) vengono segnati come spesi
|
||||
all'esterno (`utxo_spent_externally`), quelli arrivati dopo vengono
|
||||
accreditati (`deposit_credited`). Il riconciliatore risolve le righe
|
||||
`building`/`pending` del backup al suo primo passaggio con la connessione
|
||||
attiva. Controllare il registro di audit e "Transazioni pendenti".
|
||||
10. `docker compose up -d` (riavvia `caddy`) e "Riprendi lotteria".
|
||||
|
||||
All'avvio il riconciliatore delle transazioni e, dopo 300s, il controllo
|
||||
periodico dei depositi riallineano il database alla catena: UTXO spesi dopo
|
||||
il backup vengono segnati come spesi all'esterno (`utxo_spent_externally`),
|
||||
quelli arrivati dopo vengono accreditati.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once`,
|
||||
`app/deposits/reconcile.py:DepositReconciler`
|
||||
**Il rischio di replay.** Il riallineamento dei saldi è automatico e
|
||||
immediato; quello dei round no. Ogni round del backup non ancora `closed`
|
||||
riprende da solo appena c'è connessione, **anche con la lotteria in pausa**,
|
||||
e ogni scommessa fatta dopo il backup è persa come partecipazione (il suo
|
||||
UTXO risulta speso all'esterno, i suoi soldi sono nel montepremi). I passi
|
||||
4–6 esistono per questo e vanno eseguiti prima del passo 8. Dopo B-80 e B-82
|
||||
un round rigiocato paga solo gli UTXO del proprio indirizzo, dopo averli
|
||||
verificati sulla rete, e resta quindi non pagabile invece di essere pagato
|
||||
due volte; il controllo del passo 6 resta comunque il modo di sapere quali
|
||||
round del backup sono già conclusi.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/tx/reconcile.py:reconcile_once`,
|
||||
`app/electrum/listener.py:ElectrumListener._subscribe_all_users`
|
||||
|
||||
- **[OPS-08]** **Rischio di un backup vecchio: indirizzi riassegnati.** Il
|
||||
nuovo utente riceve l'indice di derivazione `max(derivation_index) + 1`.
|
||||
Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro
|
||||
indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo
|
||||
**stesso indirizzo** di un utente perso, e con esso i fondi che quello vi
|
||||
aveva depositato. Il codice non ha alcuna protezione contro questo caso, né
|
||||
un modo per sospendere le registrazioni. Dopo un ripristino da un backup non
|
||||
recentissimo, prima di riaprire il servizio, bisogna ricostruire gli utenti
|
||||
mancanti (ad esempio dai log di `POST /auth/register` in `app.log`, se ancora
|
||||
presenti) oppure accettare consapevolmente il rischio.
|
||||
aveva depositato. Oggi il codice non ha alcuna protezione: l'unica è la
|
||||
procedura [OPS-28]. Dopo B-90 l'app controlla da sola gli indici successivi
|
||||
e sospende le registrazioni
|
||||
([REG-15](registrazione-e-depositi.md#registrazione)); la procedura resta il
|
||||
modo di rimuovere la sospensione. Gli utenti persi non si possono
|
||||
ricostruire dai log: `app.log` non registra né username né indice di una
|
||||
registrazione, e il log di accesso riporta l'indirizzo di Caddy, non quello
|
||||
del client.
|
||||
*Codice:* `app/auth/routes.py:register`
|
||||
|
||||
- **[OPS-27]** Con `START_PAUSED=true` in `.env` l'app, all'avvio e prima che
|
||||
lo scheduler faccia il suo primo ciclo, mette in pausa la lotteria come una
|
||||
pausa dell'operatore (`paused_reason = 'operator'`, senza sovrascrivere un
|
||||
motivo automatico già presente, [modello-dati.md](modello-dati.md) B-85) e
|
||||
scrive `lottery_paused`; la pausa resta finché l'operatore non la toglie
|
||||
("Riprendi lotteria"). Con il valore predefinito `false` l'avvio non tocca
|
||||
la pausa.
|
||||
*Codice:* `app/config.py:Settings`, `app/main.py:lifespan`,
|
||||
`app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-90) · *Oggi:* la variabile non esiste; la pausa
|
||||
si imposta a mano nel database (passo 4 di [OPS-07]).
|
||||
|
||||
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
|
||||
ferma, valida oggi e dopo B-90:
|
||||
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
|
||||
indici successivi:
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; from app.wallet.hd import derive_user_address; M=sqlite3.connect('/app/db_data/plm_lottery.db').execute('SELECT COALESCE(MAX(derivation_index),-1) FROM users').fetchone()[0]; print('max', M); [print(i, derive_user_address(i)) for i in range(M+1, M+21)]"
|
||||
```
|
||||
2. Controllare con un explorer quali hanno una cronologia. Se ne ha uno fra
|
||||
gli ultimi della lista, ripetere il passo 1 partendo dal più alto con
|
||||
cronologia.
|
||||
3. Se nessuno ha cronologia, non c'è nulla da fare. Altrimenti inserire un
|
||||
**account segnaposto per ogni indice** da `max + 1` fino al più alto con
|
||||
cronologia (compresi quelli senza cronologia in mezzo, perché gli indici
|
||||
si assegnano in ordine):
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3, secrets; from datetime import datetime, timezone; from app.auth.security import hash_password; from app.wallet.hd import derive_user_address; c=sqlite3.connect('/app/db_data/plm_lottery.db'); A, B = <DA>, <A>; [c.execute('INSERT INTO users (username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at) VALUES (?, ?, ?, ?, 0, 0, ?)', ('restore-%d' % i, hash_password(secrets.token_urlsafe(32)), i, derive_user_address(i), datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'))) for i in range(A, B + 1)]; c.commit(); print(B - A + 1, 'segnaposto')"
|
||||
```
|
||||
La password casuale non viene mostrata a nessuno: l'account non è
|
||||
utilizzabile finché l'operatore non lo consegna. L'inserimento non
|
||||
lascia una riga di audit: annotarlo altrove.
|
||||
4. All'avvio il listener si iscrive anche agli indirizzi segnaposto e
|
||||
accredita i loro fondi. Quando un utente perso si presenta e dimostra di
|
||||
conoscere il proprio indirizzo, gli si consegna il segnaposto
|
||||
corrispondente con un reset della password ([OPS-19]).
|
||||
|
||||
Dopo B-90 il controllo automatico di
|
||||
[REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo,
|
||||
non trova più indirizzi usati oltre i segnaposto e le registrazioni
|
||||
riprendono.
|
||||
*Codice:* `app/wallet/hd.py:derive_user_address`,
|
||||
`app/auth/security.py:hash_password`, `app/db/models.py:User`
|
||||
|
||||
## Riavvio
|
||||
|
||||
Un riavvio avviene a ogni deploy (`docker compose up -d --build`), a ogni
|
||||
@@ -147,47 +305,101 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
|
||||
| nessun round / `closed` | Si apre un nuovo round rispettando il cooldown del round precedente |
|
||||
| `open` | Riprende: la scadenza è calcolata dai valori salvati sul round |
|
||||
| `closing` | Riprende: si aspetta che le scommesse in volo confermino, poi si estrae |
|
||||
| `drawing` | **Non riprende.** Il round resta in `drawing` per sempre e nessun nuovo round si apre. Serve [OPS-13] |
|
||||
| `drawing` | Riprende l'attesa dello stesso blocco di estrazione, con gli stessi partecipanti, e produce lo stesso vincitore ([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), B-81) |
|
||||
| `paying_out` | Riprende: il pagamento viene ritentato subito se non c'è un `payout_failed` negli ultimi 60s; un pagamento già in volo non viene duplicato |
|
||||
|
||||
Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal
|
||||
riconciliatore, non prima di 120 secondi dalla loro creazione e al primo
|
||||
passaggio con la connessione attiva (i passaggi sono ogni 120s, e quello
|
||||
"immediato" all'avvio di norma trova la connessione non ancora pronta e viene
|
||||
saltato): promosse se sono in catena, abbandonate altrimenti. Si perdono invece, perché solo in memoria: i
|
||||
blocchi dei login per troppi tentativi e la quota di registrazioni per IP, le
|
||||
connessioni SSE (i browser si riconnettono da soli), i lock per utente.
|
||||
riconciliatore secondo le regole di
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)
|
||||
(B-78): promosse se in catena, abbandonate solo con una prova. Si perdono
|
||||
invece, perché solo in memoria: i blocchi dei login per troppi tentativi e
|
||||
le quote per IP, le connessioni SSE (i browser si riconnettono da soli), i
|
||||
lock per utente.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
|
||||
`app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-81, B-78) · *Oggi:* un round in `drawing`
|
||||
**non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre
|
||||
(serve [OPS-13]); una riga `building` non trovata nella cronologia del
|
||||
server attivo viene abbandonata dopo 120 secondi dalla creazione, al primo
|
||||
passaggio del riconciliatore (ogni 120s) con la connessione attiva.
|
||||
|
||||
- **[OPS-10]** **Riavvio sicuro** (deploy, modifica di `.env`, rotazione di un
|
||||
segreto):
|
||||
1. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
|
||||
- **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di
|
||||
`.env`, rotazione di un segreto):
|
||||
1. **Backup** del database ([OPS-06]), copiato fuori dall'host. Annotare la
|
||||
versione in uso (`git rev-parse HEAD`) e la revisione del database
|
||||
(`docker compose exec app alembic current`): servono per un rollback
|
||||
([OPS-29]).
|
||||
2. Verificare che `SITE_ADDRESS` sia scritto in `.env`
|
||||
(`grep '^SITE_ADDRESS=' .env`). Se è stato passato solo sulla riga di
|
||||
comando (`SITE_ADDRESS=… docker compose up -d`), un semplice
|
||||
`docker compose up -d --build` lo perde e Caddy torna a `localhost` con un
|
||||
certificato autofirmato: il sito pubblico smette di funzionare.
|
||||
3. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
|
||||
(`POST /admin/pause`). Il round in corso finisce normalmente.
|
||||
2. Aspettare che `GET /rounds/current` non riporti più un round attivo
|
||||
(`round_id` vuoto) e che in "Transazioni pendenti" non ci siano scommesse
|
||||
o prelievi `building`.
|
||||
3. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
|
||||
4. Aspettare che `GET /rounds/current` non riporti più un round attivo
|
||||
(`round_id` vuoto, cioè l'ultimo round è `closed`) e che in "Transazioni
|
||||
pendenti" non ci siano righe `building` (la sezione elenca anche righe già
|
||||
confermate o fallite: guardare la colonna Stato).
|
||||
5. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
|
||||
per un cambio di `.env`).
|
||||
4. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
|
||||
6. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
|
||||
poi "Riprendi lotteria" (`POST /admin/resume`).
|
||||
|
||||
**Perché aspettare `closed` e non solo la pausa.** Oggi l'arresto non è
|
||||
pulito: con uno stream SSE aperto (basta una pagina `/` aperta) uvicorn
|
||||
aspetta che si chiuda, e Docker uccide il processo dopo 10 secondi. Ogni
|
||||
deploy è quindi, per il codice, un crash: durante `drawing` blocca il round
|
||||
([OPS-13]), durante un invio lascia una riga `building`. Dopo B-93 l'arresto
|
||||
chiude gli stream e le attività entro 30 secondi; il riavvio
|
||||
durante `drawing` diventa sicuro con B-81 ([OPS-09]).
|
||||
|
||||
La pausa non blocca scommesse e prelievi sul round in corso, né
|
||||
registrazioni e depositi. Un riavvio con il round in `open`, `closing` o
|
||||
`paying_out` è comunque sicuro ([OPS-09]); quello da evitare è il riavvio
|
||||
durante `drawing`.
|
||||
registrazioni e depositi.
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery`,
|
||||
`app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
|
||||
Dopo B-93 lo spegnimento chiude gli stream SSE e attende le attività, entro
|
||||
`stop_grace_period: 30s` ([architettura.md](architettura.md) ARC-22); ogni
|
||||
rilascio ha un'immagine con un tag di versione e `SITE_ADDRESS` è
|
||||
obbligatorio in `.env`: senza, `docker compose` rifiuta di partire invece di
|
||||
ripiegare su `localhost` ([architettura.md](architettura.md) ARC-01, ARC-05).
|
||||
|
||||
- **[OPS-29]** **Rollback a una versione precedente.** Un'immagine vecchia non
|
||||
parte su un database con migrazioni più nuove: il suo `alembic upgrade head`
|
||||
non riconosce la revisione del database e il container esce, e si riavvia in
|
||||
continuazione. Il database va quindi riportato indietro **con l'immagine
|
||||
nuova, prima** di cambiare versione:
|
||||
1. Pausa e attesa di `closed` come in [OPS-10], poi backup ([OPS-06]).
|
||||
2. Trovare la revisione di destinazione: nella versione a cui si torna,
|
||||
dal venv, `alembic heads` (legge solo i file di migrazione, non il
|
||||
database).
|
||||
3. `docker compose stop app`, poi, con l'immagine nuova ancora in uso,
|
||||
`docker compose run --rm --no-deps app alembic downgrade <revisione>`.
|
||||
4. `git checkout <versione>` e `docker compose up -d --build`.
|
||||
5. Controllare il log e riprendere la lotteria.
|
||||
|
||||
Un downgrade cancella le colonne e le tabelle aggiunte dalle migrazioni
|
||||
annullate, con i loro dati. Se un downgrade fallisce, si ripristina il
|
||||
backup del passo 1 di [OPS-10] con [OPS-07], perdendo ciò che è successo
|
||||
dopo il deploy. Dopo B-93 il passo 4 usa il tag dell'immagine precedente
|
||||
invece di ricostruirla.
|
||||
*Codice:* `migrations/`, `Dockerfile` (`CMD`)
|
||||
|
||||
## Procedure di intervento
|
||||
|
||||
Ogni procedura ha la stessa forma: come si riconosce, perché succede, cosa
|
||||
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host (i messaggi
|
||||
dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
[audit-log.md](audit-log.md)).
|
||||
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host. I messaggi
|
||||
dell'applicazione (scheduler, listener, riconciliatori) sono **solo** in quel
|
||||
file; `docker compose logs app` mostra soltanto l'output di avvio del
|
||||
container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
|
||||
conto suo (avvio, richieste, errori di avvio). Vedi
|
||||
[audit-log.md](audit-log.md#log-applicativo).
|
||||
|
||||
Dopo B-92 il log applicativo va anche sull'output del container e si legge
|
||||
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
|
||||
### Electrum irraggiungibile
|
||||
|
||||
@@ -195,39 +407,71 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
**Sintomi.** In `/admin` la pillola "Blocco #N" e `chain_tip_height` di
|
||||
`GET /rounds/current` restano fermi sull'ultimo valore noto (l'altezza non si
|
||||
azzera alla disconnessione, quindi *non* si vede un errore, si vede un numero
|
||||
che non cresce). Scommesse e prelievi rispondono 503 `network_unavailable`.
|
||||
Nel log: `Electrum session on … failed`, `Electrum session on … ending`,
|
||||
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
|
||||
altre attività saltano i loro cicli; un round in `drawing` continua ad
|
||||
aspettare e dopo 360s scrive `draw_stalled`.
|
||||
**Causa.** Tutti i server configurati sono giù, irraggiungibili, o rifiutati
|
||||
(un header che non supera la validazione chiude la sessione e fa passare al
|
||||
server successivo).
|
||||
che non cresce). Due casi diversi:
|
||||
- **server irraggiungibili**: scommesse e prelievi rispondono 503
|
||||
`network_unavailable`; nel log `Electrum session on … failed`,
|
||||
`Electrum session on … ending`,
|
||||
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
|
||||
altre attività saltano i loro cicli;
|
||||
- **server connesso ma fermo**: nessun errore nel log, l'ultima riga di
|
||||
connessione è `Electrum connected to <server>`, `ping` riesce, ma
|
||||
l'altezza non cresce mentre un explorer o un altro server riportano
|
||||
blocchi nuovi. Scommesse e prelievi vengono accettati, ma nulla conferma.
|
||||
|
||||
In entrambi i casi un round in `drawing` continua ad aspettare e dopo 360s
|
||||
scrive `draw_stalled`.
|
||||
**Causa.** Nel primo caso tutti i server configurati sono giù,
|
||||
irraggiungibili o rifiutati (un header che non supera la validazione chiude
|
||||
la sessione e fa passare al server successivo). Nel secondo il server attivo
|
||||
ha smesso di seguire la catena: il listener lo rileva solo se la connessione
|
||||
cade.
|
||||
**Cosa fare.**
|
||||
1. Nessuna azione è necessaria per il ripristino: il listener prova il
|
||||
server successivo subito e, dopo un giro completo di fallimenti, riprova
|
||||
con attesa crescente da 1 a 30 secondi, all'infinito.
|
||||
2. Verificare la raggiungibilità dal server:
|
||||
`docker compose exec app python scripts/electrum_smoke_test.py` (prova
|
||||
solo il primario, vedi [OPS-26]).
|
||||
3. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
|
||||
1. *Server irraggiungibili*: il listener prova il server successivo subito
|
||||
e, dopo un giro completo di fallimenti, riprova con attesa crescente da 1
|
||||
a 30 secondi, all'infinito. Se un server torna, la connessione si
|
||||
ristabilisce da sola.
|
||||
2. *Server fermo*: il ripristino **non** è automatico. Un riavvio riparte
|
||||
sempre dal primario (`ELECTRUM_HOST`), quindi aiuta solo se il server
|
||||
fermo è una riserva. Se il fermo è il primario, in `.env` sostituirlo con
|
||||
un server che funziona (spostandolo tra le riserve o togliendolo) e
|
||||
riavviare con [OPS-10]. Dopo B-86 il listener cambia server da solo
|
||||
quando la punta non avanza da 360s e un altro server ne riporta una più
|
||||
alta ([ELC-21](connessione-electrum.md#3-caduta-rilevata)).
|
||||
3. Verificare un server dal container, senza toccare quello in esecuzione:
|
||||
`docker compose run --rm --no-deps -e ELECTRUM_HOST=<host> -e ELECTRUM_PORT=<porta> app python scripts/electrum_smoke_test.py`
|
||||
(senza `-e` prova il primario, vedi [OPS-26]). L'altezza stampata si
|
||||
confronta con quella di `GET /rounds/current`.
|
||||
4. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
|
||||
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
|
||||
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
|
||||
riavviare. **Se un round è in `drawing`, il riavvio lo blocca**: seguire
|
||||
poi [OPS-13].
|
||||
riavviare. **Se un round è in `drawing`, oggi il riavvio lo blocca**:
|
||||
seguire poi [OPS-13].
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
|
||||
`app/electrum/listener.py:ElectrumListener._keepalive`,
|
||||
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
|
||||
- **[OPS-12]** Con `ELECTRUM_FALLBACK_SERVERS` vuoto la corroborazione passa
|
||||
sempre (non c'è nessuno a cui chiedere), ma un solo server guasto ferma
|
||||
tutto. Con server di riserva configurati ma tutti irraggiungibili, la
|
||||
corroborazione **fallisce**: depositi non accreditati, estrazione che scarta
|
||||
ogni blocco (`draw_header_corroboration_failed`) e segnalazioni di spesa
|
||||
esterna ignorate, anche se il server attivo funziona. In produzione servono
|
||||
almeno due server di riserva raggiungibili.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
- **[OPS-12]** Le regole del quorum sono di
|
||||
[connessione-electrum.md](connessione-electrum.md): almeno 2 server di
|
||||
riserva configurati o il server non parte
|
||||
([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), almeno 2
|
||||
risposte per ogni corroborazione
|
||||
([ELC-14](connessione-electrum.md#5-il-quorum)). La conseguenza operativa:
|
||||
con meno di 2 server di riserva **raggiungibili** ogni corroborazione
|
||||
fallisce, anche se il server attivo funziona — depositi non accreditati,
|
||||
estrazione in attesa, segnalazioni di spesa esterna ignorate, nessun denaro
|
||||
mosso. In produzione vanno quindi configurati almeno 3 server di riserva,
|
||||
così che la caduta di uno non fermi la lotteria. `ALLOW_NO_QUORUM=true` non
|
||||
si usa mai in produzione.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`,
|
||||
`app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* con `ELECTRUM_FALLBACK_SERVERS`
|
||||
vuoto (il valore di `.env.example`) il server parte e la corroborazione
|
||||
passa sempre: un solo server decide tutto. Con server di riserva la
|
||||
maggioranza è su chi risponde, quindi basta una sola riserva raggiungibile;
|
||||
se nessuna risponde, la corroborazione fallisce
|
||||
(`draw_header_corroboration_failed` a ogni blocco).
|
||||
|
||||
### Round bloccato in `drawing`
|
||||
|
||||
@@ -249,12 +493,17 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
|
||||
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
|
||||
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
|
||||
Dopo B-81 lo scheduler riprende da solo un round in `drawing` e il terzo
|
||||
caso scompare.
|
||||
|
||||
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
|
||||
server di riserva): l'estrazione avviene da sola al primo blocco valido.
|
||||
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
|
||||
intervento possibile è una modifica manuale del database che riporti il round
|
||||
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo:
|
||||
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo.
|
||||
**Vale solo per un round bloccato nell'istanza in esercizio: un round in
|
||||
`drawing` arrivato da un backup ripristinato si tratta con [OPS-07],
|
||||
passo 6**, perché potrebbe essere già stato pagato.
|
||||
|
||||
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
|
||||
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
|
||||
@@ -296,15 +545,18 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
**Cosa fare.**
|
||||
1. Controllare `attempt_count` e `fee_rate_sat_vb` della riga: il
|
||||
`RbfBumper` alza la commissione di 1 sat/vB ogni `rbf_timeout_seconds`
|
||||
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà da
|
||||
sola.
|
||||
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà
|
||||
quando la commissione basta al mempool.
|
||||
2. Se nel log compare `could not bump pending_transaction N`, l'RBF è
|
||||
impossibile: vedi [OPS-17].
|
||||
3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve
|
||||
al suo prossimo passaggio (ogni 120s).
|
||||
4. Una transazione che il server non conosce più viene abbandonata dopo 6
|
||||
ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
|
||||
rimosso e il round può chiudere.
|
||||
4. Una transazione che il server attivo non conosce più viene abbandonata
|
||||
dopo 6 ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
|
||||
rimosso e il round può chiudere. Dopo B-78 l'abbandono richiede una prova
|
||||
(ingressi spesi altrove, o un nuovo invio rifiutato da tutti i server):
|
||||
una transazione che nessuno conosce ma i cui ingressi non sono spesi non
|
||||
viene più abbandonata per il solo passare del tempo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
|
||||
@@ -317,7 +569,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
`payout_failed` dello stesso round, al massimo una al minuto. Il campo
|
||||
`reason` dice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una
|
||||
volta rimossa la causa, il pagamento parte al tentativo successivo senza
|
||||
altri interventi.
|
||||
altri interventi. Un round in `paying_out` senza `payout_failed` e con una
|
||||
riga `payout` `pending` che non conferma è un pagamento fermo nel mempool:
|
||||
vedi [OPS-17].
|
||||
|
||||
| `reason` | Cosa fare |
|
||||
|---|---|
|
||||
@@ -327,9 +581,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
| `insufficient pool UTXOs` | Vedi sotto |
|
||||
| `too_many_inputs` | Il pagamento richiederebbe più di 500 UTXO del montepremi (resti accumulati in molti round). Serve consolidare gli UTXO del montepremi con la sua chiave: **nessuno strumento lo fa**, vedi [OPS-24] |
|
||||
| `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata |
|
||||
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto circa 1.000 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
|
||||
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto 980 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
|
||||
| `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) |
|
||||
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore entro 2–4 minuti, `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb` |
|
||||
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore fra 120 e 240 secondi dopo (al primo passaggio oltre i 120s), `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb`. Oggi lo stesso motivo compare anche per un timeout o una connessione caduta durante l'invio, quando la transazione potrebbe essere partita; dopo B-76 solo un rifiuto esplicito libera la riga |
|
||||
|
||||
**`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati**
|
||||
dell'indirizzo del montepremi, escludendo quelli già riservati da un altro
|
||||
@@ -338,7 +592,11 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è
|
||||
mostrato dal pannello; si ottiene con
|
||||
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`
|
||||
e se ne può controllare il saldo con un explorer.
|
||||
e se ne può controllare il saldo con un explorer. Dopo B-80 ogni round ha
|
||||
il proprio indirizzo (`rounds.pool_address`), e dopo B-82 un'uscita di
|
||||
scommessa scomparsa dalla catena esclude quel partecipante invece di
|
||||
bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca
|
||||
([OPS-30]).
|
||||
|
||||
Un pagamento già in volo (riga `payout` `building` o `pending` del round)
|
||||
non viene mai duplicato: finché esiste, i tentativi vengono saltati senza
|
||||
@@ -354,16 +612,27 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
**Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log
|
||||
`abandoned <tipo> pending_transaction …`; la riga in "Transazioni pendenti"
|
||||
passa a `failed`.
|
||||
**Causa.** La transazione non compare più nella cronologia del proprio
|
||||
indirizzo: mai partita (riga `building` da oltre 120s) o scomparsa dal
|
||||
mempool (riga `pending` da oltre 6 ore dal primo invio).
|
||||
**Causa.** Oggi: la transazione non compare nella cronologia del proprio
|
||||
indirizzo secondo il **solo server attivo**: mai partita (riga `building` da
|
||||
oltre 120s) o scomparsa dal mempool (riga `pending` da oltre 6 ore dal primo
|
||||
invio). Non è una prova: un server diverso da quello che ha ricevuto l'invio
|
||||
può non conoscerla, e una versione precedente di una transazione accelerata
|
||||
con RBF non viene cercata (B-77).
|
||||
**Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e
|
||||
saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può
|
||||
riscommettere se il round è ancora aperto); per un prelievo la richiesta è
|
||||
`failed` e l'utente, che non vede lo storico, va avvisato di ripeterla; per
|
||||
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo. Se
|
||||
gli abbandoni sono frequenti, la commissione di rete configurata è
|
||||
probabilmente troppo bassa.
|
||||
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo.
|
||||
**Prima di avvisare l'utente o lasciar ripartire un pagamento, verificare
|
||||
con un explorer** che né `txid` del payload né una sua versione precedente
|
||||
(`replaced_by_txid` della riga) siano in catena: se lo sono, i fondi si sono
|
||||
mossi davvero e il database dice il contrario. Per un prelievo, gli UTXO
|
||||
liberati verranno poi segnati come spesi all'esterno dalla corroborazione;
|
||||
per un pagamento già arrivato al vincitore, mettere subito in pausa la
|
||||
lotteria e trattare il round come in [OPS-07], passo 6. Se gli abbandoni
|
||||
sono frequenti, la commissione di rete configurata è probabilmente troppo
|
||||
bassa. Dopo B-78 si abbandona solo con una prova, e una riga `failed` che
|
||||
conferma più tardi mette in pausa la lotteria da sola ([OPS-30]).
|
||||
*Codice:* `app/tx/reconcile.py:_abandon` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
|
||||
@@ -373,17 +642,26 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
|
||||
`could not bump pending_transaction N` con un `RbfError`: "no change output
|
||||
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
|
||||
Nessuna riga di audit. `attempt_count` smette di salire.
|
||||
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
|
||||
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
|
||||
polvere) compare invece come errore di invio nello stesso messaggio.
|
||||
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
|
||||
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
|
||||
non si può fare.
|
||||
non si può fare. **Un pagamento di norma non ha resto** (spende esattamente
|
||||
gli UTXO del montepremi), quindi oggi un pagamento fermo nel mempool non si
|
||||
può accelerare in nessun caso.
|
||||
**Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa
|
||||
alla commissione che ha: conferma da sola quando il mempool si svuota,
|
||||
oppure, se sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se
|
||||
blocca un round in `closing` ([OPS-14]), l'alternativa manuale è spendere il
|
||||
resto non confermato con una commissione alta (CPFP) usando la chiave
|
||||
dell'utente ([OPS-23]); il sistema accrediterà l'uscita della nuova
|
||||
transazione come un normale deposito.
|
||||
alla commissione che ha: conferma quando il mempool la accetta, oppure, se
|
||||
sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se blocca un
|
||||
round in `closing` ([OPS-14]), l'alternativa manuale è spendere il resto non
|
||||
confermato con una commissione alta (CPFP) usando la chiave dell'utente
|
||||
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
|
||||
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
|
||||
chiave del montepremi non è esportabile ([OPS-24]).
|
||||
Dopo B-79 ogni pagamento ha un'uscita di riserva che l'RBF può ridurre;
|
||||
dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
|
||||
toglie il resto invece di fallire, e ogni fallimento scrive
|
||||
`rbf_bump_failed`.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
|
||||
@@ -392,8 +670,10 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
- **[OPS-18]**
|
||||
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
|
||||
**Cause, in ordine di probabilità.**
|
||||
1. La transazione non ha ancora 1 conferma: il deposito non viene mostrato
|
||||
nemmeno come "in attesa" finché non è in un blocco.
|
||||
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
|
||||
`deposit_confirmations` (predefinito 6) e 120 per un'uscita coinbase
|
||||
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
|
||||
Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha.
|
||||
2. La corroborazione fallisce: nel log
|
||||
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
|
||||
spesso `could not corroborate … with any of N other configured server(s)`.
|
||||
@@ -401,13 +681,16 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
3. La sottoscrizione dell'indirizzo si è persa (log
|
||||
`could not subscribe a newly-registered user's address` o
|
||||
`resubscribing all users failed partway through`).
|
||||
4. Electrum irraggiungibile ([OPS-11]).
|
||||
**Cosa fare.** Verificare con un explorer che la transazione sia confermata
|
||||
e paghi esattamente l'indirizzo dell'utente (sezione Utenti del pannello).
|
||||
Poi aspettare: `DepositReconciler` ricontrolla ogni indirizzo ogni 300
|
||||
secondi e accredita tutto ciò che il quorum conferma, indipendentemente
|
||||
dalle notifiche. Un riavvio risottoscrive tutti gli indirizzi, ma non serve
|
||||
e va fatto con [OPS-10]. Ogni accredito lascia una riga `deposit_credited`.
|
||||
4. Electrum irraggiungibile o fermo ([OPS-11]).
|
||||
5. Dopo B-75: l'importo è sotto `DUST_LIMIT_SATS` (294 sat) e non viene
|
||||
accreditato per scelta.
|
||||
**Cosa fare.** Verificare con un explorer che la transazione abbia le
|
||||
conferme richieste e paghi esattamente l'indirizzo dell'utente (sezione
|
||||
Utenti del pannello). Poi aspettare: `DepositReconciler` ricontrolla ogni
|
||||
indirizzo ogni 300 secondi e accredita tutto ciò che il quorum conferma,
|
||||
indipendentemente dalle notifiche. Un riavvio risottoscrive tutti gli
|
||||
indirizzi, ma non serve e va fatto con [OPS-10]. Ogni accredito lascia una
|
||||
riga `deposit_credited`.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`,
|
||||
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`,
|
||||
@@ -421,11 +704,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
un canale di cui ci si fida (non esiste un'email). Tutte le sessioni esistenti
|
||||
dell'utente vengono invalidate. Resta la riga `admin_password_reset`.
|
||||
Verificare l'identità di chi chiede: chi riceve la password controlla i fondi.
|
||||
**Troppi tentativi.** Dopo 5 login falliti sullo stesso username (20 dallo
|
||||
stesso IP) ogni ulteriore errore impone un'attesa che parte da 2 secondi e
|
||||
raddoppia fino a 300; il contatore si azzera dopo 15 minuti senza errori o al
|
||||
primo login riuscito. Basta aspettare; un riavvio azzera tutti i contatori
|
||||
ma non è necessario.
|
||||
**Troppi tentativi.** Il login ha un freno per username e uno per IP
|
||||
([SEC-30](sicurezza.md#sec-30)). Basta aspettare che il blocco scada; un
|
||||
riavvio azzera tutti i contatori ma non è necessario.
|
||||
*Codice:* `app/api/routes/admin.py:reset_user_password`,
|
||||
`app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_rate_limit.py`
|
||||
@@ -462,22 +743,83 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
`app/config.py:validate_runtime_secrets`
|
||||
|
||||
- **[OPS-22]** **Rotazione di `XPRV_ENCRYPTION_KEY`** (la master key non
|
||||
cambia, cambia solo la sua cifratura):
|
||||
1. `PYTHONPATH=. python scripts/decrypt_master_key.py` con la chiave vecchia
|
||||
in `.env`, e conservare la xprv stampata solo per il tempo necessario.
|
||||
2. Generare la chiave nuova
|
||||
cambia, cambia solo la sua cifratura). Tutti i passi girano **nel
|
||||
container**, dove `MASTER_KEY_PATH` è forzato su
|
||||
`/app/key_data/master.xprv.enc`, cioè il file che l'app legge:
|
||||
0. **Verificare quale file si sta ruotando.** Il file dell'app è
|
||||
`./data/keys/master.xprv.enc`. Dal venv gli script usano invece il
|
||||
`MASTER_KEY_PATH` di `.env` (`grep '^MASTER_KEY_PATH=' .env`), che su
|
||||
questa installazione punta alla copia `./master.xprv.enc` nella radice
|
||||
(B-71): una rotazione fatta dal venv riscriverebbe quella copia, l'app
|
||||
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
|
||||
tornare indietro: `cp data/keys/master.xprv.enc data/keys/master.xprv.enc.old`.
|
||||
2. Annotare gli indirizzi derivati con la chiave vecchia:
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "from app.wallet.hd import derive_pool_address, derive_user_address; print(derive_pool_address(), derive_user_address(0))"
|
||||
```
|
||||
3. `docker compose run --rm --no-deps app python scripts/decrypt_master_key.py`
|
||||
con la chiave vecchia ancora in `.env`, e conservare la xprv stampata
|
||||
solo per il tempo necessario.
|
||||
4. Generare la chiave nuova
|
||||
(`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`)
|
||||
e scriverla in `.env`.
|
||||
3. `PYTHONPATH=. python scripts/encrypt_master_key.py --overwrite` e
|
||||
incollare la xprv.
|
||||
4. Riavviare ([OPS-10]) e aggiornare i backup ([OPS-05]).
|
||||
e scriverla in `.env` al posto della vecchia.
|
||||
5. `docker compose run --rm --no-deps app python scripts/encrypt_master_key.py --overwrite`
|
||||
e incollare la xprv.
|
||||
6. **Verificare la decifratura con la chiave nuova prima di riavviare**:
|
||||
rilanciare il comando del passo 2. Deve stampare gli stessi due
|
||||
indirizzi. Se dà errore o indirizzi diversi, non avviare l'app: rimettere
|
||||
in `.env` la chiave vecchia e `master.xprv.enc.old` al posto del file, e
|
||||
ricominciare.
|
||||
7. `docker compose up -d`, controllare il log, riprendere la lotteria.
|
||||
8. Aggiornare i backup ([OPS-05]) e cancellare `master.xprv.enc.old`. Se la
|
||||
rotazione è dovuta a una fuga della chiave vecchia, distruggere anche ogni
|
||||
copia cifrata con essa: la copia nella radice (`./master.xprv.enc`, B-71)
|
||||
e i backup precedenti del file. Il file scritto dal container appartiene a
|
||||
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
|
||||
Dopo B-89 gli script scrivono `0600` e rifiutano una xprv i cui
|
||||
indirizzi non coincidono con quelli del database
|
||||
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non
|
||||
in `.env` ([SEC-53](sicurezza.md#sec-53)).
|
||||
|
||||
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
|
||||
xprv in chiaro), sono a rischio tutti i fondi degli utenti e del montepremi.
|
||||
Non esiste una procedura supportata per passare a una nuova master key: ogni
|
||||
indirizzo, compreso quello del montepremi, deriva da essa, e cambiarla
|
||||
cambierebbe tutti gli indirizzi senza spostare i fondi né aggiornare il
|
||||
database.
|
||||
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`
|
||||
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`,
|
||||
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`
|
||||
|
||||
### Pausa automatica
|
||||
|
||||
- **[OPS-30]** Oltre all'operatore, il sistema mette in pausa la lotteria da
|
||||
sé in tre casi, ognuno con il proprio motivo (`paused_reason`, valori in
|
||||
[modello-dati.md](modello-dati.md) B-85) e una riga di audit. La pausa ha
|
||||
l'effetto di sempre: nessun round nuovo si apre, quello in corso prosegue
|
||||
salvo dove indicato. Solo l'operatore la toglie ("Riprendi lotteria", che
|
||||
azzera anche il motivo); `/admin` mostra il motivo. Prima di riprendere
|
||||
l'operatore esegue la verifica della tabella.
|
||||
|
||||
| Motivo | Quando | Cosa fare prima di riprendere |
|
||||
|---|---|---|
|
||||
| `operator` | "Interrompi dopo questo round", oppure avvio con `START_PAUSED=true` ([OPS-27]) | Dopo un ripristino, le verifiche di [OPS-07] |
|
||||
| `reorg` | Riorganizzazione della catena profonda almeno 2 blocchi (`chain_reorg_detected`, [ELC-24](connessione-electrum.md#riorganizzazioni), B-85) | Confrontare con un explorer e con i server di riserva ([OPS-11], passo 3) la punta attuale e l'altezza di biforcazione del payload. Controllare le righe `participant_reorged_out`, `utxo_spent_externally` e `pending_tx_resurrected` scritte dopo. Riprendere quando la punta ha superato l'altezza di biforcazione di almeno `deposit_confirmations` blocchi senza un'altra `chain_reorg_detected` |
|
||||
| `resurrected_tx` | Una transazione dichiarata `failed` è stata confermata (`pending_tx_resurrected`, [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione), B-78) | La riga torna `confirmed`, ma le righe collegate restano come le aveva lasciate l'abbandono: il database dice che i fondi non si sono mossi, la catena il contrario. Secondo `kind` del payload: *prelievo* → l'utente ha ricevuto i fondi, e il prelievo risulta `failed`; gli UTXO liberati verranno segnati come spesi all'esterno dalla corroborazione, ma nel frattempo erano di nuovo spendibili: controllare che l'utente non li abbia già prelevati o scommessi con un'altra transazione; *scommessa* → i soldi sono nel montepremi del round senza partecipante: rimborso manuale ([OPS-24]); *pagamento* → il vincitore potrebbe essere stato pagato due volte: confrontare `payout_txid` del round con la catena |
|
||||
| `payout_blocked` | La scommessa del vincitore non è più sulla catena ([RND-39](ciclo-di-vita-del-round.md#4--pagamento), B-82) | Il round resta in `paying_out` con `payout_blocked_at`. Verificare con un explorer che l'uscita della scommessa sia davvero spesa altrove (doppia spesa del deposito): è il segno di un probabile attacco, e le prove vanno conservate. Alla ripresa la verifica di RND-39 si ripete: se la scommessa è ancora assente, il pagamento resta bloccato e la lotteria torna in pausa |
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery`,
|
||||
`app/api/routes/admin.py:resume_lottery`,
|
||||
`app/rounds/service.py:open_new_round_if_needed`,
|
||||
`app/electrum/listener.py:ElectrumListener._apply_header`,
|
||||
`app/tx/confirmation.py:poll_once`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-85, B-78, B-82, B-90) · *Oggi:* esiste solo la
|
||||
pausa dell'operatore, senza motivo; nessun evento del sistema mette in pausa
|
||||
la lotteria.
|
||||
|
||||
### Intervento manuale sui fondi
|
||||
|
||||
@@ -508,11 +850,15 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
(`m/84'/746'/0'/1/0` della master key) non è esportabile dal pannello né da
|
||||
alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta con
|
||||
`scripts/decrypt_master_key.py`. Non c'è quindi una procedura supportata per
|
||||
consolidare gli UTXO del montepremi (`too_many_inputs`) o per recuperare
|
||||
resti accumulati. Spendere UTXO del montepremi fuori dal sistema non è
|
||||
rilevato come spesa esterna (l'indirizzo non è in `utxo_events`) e può far
|
||||
fallire il pagamento di un round in corso (`insufficient pool UTXOs` o una
|
||||
trasmissione rifiutata): farlo solo senza round in `paying_out`.
|
||||
consolidare gli UTXO del montepremi (`too_many_inputs`), per recuperare
|
||||
resti accumulati o per rimborsare una scommessa rimasta senza partecipante.
|
||||
Spendere UTXO del montepremi fuori dal sistema non è rilevato come spesa
|
||||
esterna (l'indirizzo non è in `utxo_events`) e può far fallire il pagamento
|
||||
di un round in corso (`insufficient pool UTXOs` o una trasmissione
|
||||
rifiutata): farlo solo senza round in `paying_out`. Dopo B-80 ogni round ha
|
||||
il proprio indirizzo `m/84'/746'/0'/2/<round_id>` e `m/84'/746'/0'/1/0`
|
||||
diventa l'indirizzo di riserva; il pagamento di un round spende solo le
|
||||
uscite delle sue scommesse.
|
||||
*Codice:* `app/wallet/hd.py:derive_pool_key`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`
|
||||
|
||||
@@ -523,28 +869,72 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
|
||||
|
||||
| Segnale | Dove | Allarme se |
|
||||
|---|---|---|
|
||||
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di ~10 minuti (blocchi ogni 120s): Electrum giù ([OPS-11]) |
|
||||
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di qualche blocco; `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
|
||||
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di 10 minuti (blocchi ogni 120s): Electrum giù o fermo ([OPS-11]) |
|
||||
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di 3 blocchi (6 minuti); `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
|
||||
| Istanza configurata | `lottery_configured` in `GET /rounds/current` | `false`: manca `fee_address`, nessun round si apre |
|
||||
| Pausa dimenticata | `lottery_paused` in `GET /rounds/current` | `true` fuori da una manutenzione |
|
||||
| Pagamenti ed estrazioni | Audit: `payout_failed`, `draw_stalled`, `draw_header_corroboration_failed` | Qualunque occorrenza |
|
||||
| Transazioni perse | Audit: `pending_tx_abandoned` | Frequenti: commissione troppo bassa |
|
||||
| Transazioni perse | Audit: `pending_tx_abandoned` | Qualunque occorrenza va verificata ([OPS-16]); frequenti: commissione troppo bassa |
|
||||
| Spese esterne | Audit: `utxo_spent_externally` | Non dovute a un intervento dell'operatore: chiave dell'utente compromessa o server che mente |
|
||||
| Uso del pannello | Audit: `admin_privkey_accessed`, `config_updated`, `admin_password_reset` | Non attribuibili a un operatore: `ADMIN_TOKEN` compromesso ([OPS-20]) |
|
||||
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` vecchio di ore |
|
||||
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` (primo invio) vecchio di ore |
|
||||
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
|
||||
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
|
||||
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
|
||||
| Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
|
||||
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
|
||||
|
||||
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
|
||||
`app/logging_config.py:setup_logging`
|
||||
|
||||
Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
|
||||
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
|
||||
connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
|
||||
solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
|
||||
connessione caduta.
|
||||
|
||||
## Prova su rete reale
|
||||
|
||||
I test automatici simulano la rete. Nessuna delle falle critiche della
|
||||
revisione del 2026-09-28 sarebbe emersa da essi, e prelievo e RBF non sono mai
|
||||
stati trasmessi davvero. Prima di aprire al pubblico, e dopo le correzioni di
|
||||
[piano-correzioni.md](piano-correzioni.md), serve una prova su un'istanza di
|
||||
staging collegata alla rete PLM reale (o, dove indicato, a una rete di prova),
|
||||
con importi piccoli (costo della scommessa 0,1 PLM) e guasti provocati di
|
||||
proposito.
|
||||
|
||||
- **[OPS-31]** L'istanza si apre al pubblico solo dopo che ogni prova della
|
||||
tabella è stata eseguita e ha dato l'esito atteso. Per ogni prova si
|
||||
annotano data, versione (`git rev-parse HEAD`), txid coinvolti ed esito, e
|
||||
una prova con esito diverso diventa una voce di [BUGS.md](../BUGS.md).
|
||||
*Codice:* —
|
||||
|
||||
| # | Prova | Come | Esito atteso |
|
||||
|---|---|---|---|
|
||||
| 1 | Ciclo completo | Due utenti, depositi, una scommessa ciascuno, un round intero | Deposito accreditato dopo `deposit_confirmations` conferme; `draw_committed` all'ingresso in `drawing`; vincitore, seme rivelato e prova di `GET /rounds/{id}/proof` ricalcolabili a mano; ripartizione 70/30 al satoshi; round `closed` e round successivo aperto |
|
||||
| 2 | Prelievo reale | Prelievo parziale e prelievo dell'intero saldo | Transazione confermata; importo e resto come in [scommesse-e-prelievi.md](scommesse-e-prelievi.md); il prelievo totale lascia un resto di almeno `DUST_LIMIT_SATS` |
|
||||
| 3 | Server ucciso durante un invio | Interrompere la rete del server Electrum attivo (o `docker compose kill app`) mentre una scommessa e un prelievo sono in invio | Nessun rilascio senza prova (B-76): la risposta è 202 o l'errore esplicito; la riga resta `building` e il riconciliatore la promuove se la transazione è in catena; nessun UTXO liberato per una transazione partita |
|
||||
| 4 | Riavvio durante `drawing` | `docker compose restart app` con un round in `drawing` | Il round riprende da solo (B-81) con lo stesso `draw_height` e gli stessi partecipanti, e il vincitore è quello calcolabile dalla prova |
|
||||
| 5 | Pagamento fermo a commissione bassa | `fee_rate_sat_vb` = 1 con mempool affollato, oppure invio del pagamento a un solo server che non lo propaga | Dopo `rbf_timeout_seconds` il pagamento viene accelerato riducendo l'uscita di riserva (B-79) e conferma; se l'aumento è impossibile compare `rbf_bump_failed` (B-87) |
|
||||
| 6 | Riorganizzazione di 1 blocco | Solo su una rete di prova con due minatori, se disponibile | La punta segue il ramo nuovo senza chiudere la sessione; `chain_reorg_detected` con profondità 1; nessuna pausa. Con profondità 2: pausa con motivo `reorg` (B-85, B-74) |
|
||||
| 7 | Cronologia molto lunga | Indirizzo con risposta oltre 64 KiB (per esempio mille piccoli invii a un indirizzo di prova) | La connessione resta aperta (limite di 16 MiB); le uscite sotto `DUST_LIMIT_SATS` non vengono accreditate (B-75) |
|
||||
| 8 | Tutte le riserve giù | Bloccare con il firewall ogni server di `ELECTRUM_FALLBACK_SERVERS` | Accrediti rinviati, estrazione in attesa, nessuna spesa esterna registrata, nessun denaro mosso; al ritorno dei server tutto riprende. Un avvio con meno di 2 riserve è rifiutato (B-84) |
|
||||
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 nel frattempo (B-92) |
|
||||
| 10 | Salto dell'orologio | Portare avanti l'orologio dell'host di 7 ore con transazioni `pending` | Nessuna transazione abbandonata per il solo tempo trascorso (B-78) |
|
||||
| 11 | Disco pieno | Riempire il filesystem di `./data/db` durante l'attività | Nessuna transazione trasmessa senza la sua riga salvata prima; scommesse e prelievi falliscono prima dell'invio; liberato lo spazio, il sistema riprende senza interventi sul database |
|
||||
| 12 | Ripristino da backup | Backup, poi un round completo e una registrazione, poi [OPS-07] | Avvio in pausa con `START_PAUSED=true`; `restore_gap_detected` e registrazioni rifiutate; il round rigiocato non viene pagato una seconda volta (B-90, B-80, B-82) |
|
||||
| 13 | Chiave sbagliata | Avvio con una `XPRV_ENCRYPTION_KEY` diversa, poi con una master key diversa | Il server rifiuta di servire in entrambi i casi (B-89) |
|
||||
| 14 | Deploy con stream aperti | `docker compose up -d --build` con pagine `/` aperte | Arresto completo entro 30 secondi senza uccisione del processo (B-93) |
|
||||
|
||||
## Script manuali
|
||||
|
||||
Si lanciano dal venv sull'host (`source .venv/bin/activate`, con
|
||||
`PYTHONPATH=.`) oppure nel container con `docker compose run --rm app python
|
||||
scripts/<nome>.py`. Nel container `MASTER_KEY_PATH` punta a
|
||||
Si lanciano nel container con `docker compose run --rm --no-deps app python
|
||||
scripts/<nome>.py`, oppure dal venv sull'host (`source .venv/bin/activate`,
|
||||
con `PYTHONPATH=.`). Nel container `MASTER_KEY_PATH` punta a
|
||||
`/app/key_data/master.xprv.enc`, cioè allo stesso file `./data/keys` dell'host;
|
||||
dal venv conta il `MASTER_KEY_PATH` di `.env`.
|
||||
dal venv conta il `MASTER_KEY_PATH` di `.env`, che su questa installazione
|
||||
punta a un'altra copia (B-71, vedi [OPS-22], passo 0): per gli script sulla
|
||||
master key usare il container.
|
||||
|
||||
- **[OPS-26]** Gli script disponibili:
|
||||
|
||||
@@ -553,10 +943,14 @@ dal venv conta il `MASTER_KEY_PATH` di `.env`.
|
||||
| `scripts/generate_master_key.py` | Crea una master key casuale, la cifra con `XPRV_ENCRYPTION_KEY` e la scrive in `MASTER_KEY_PATH`. Si rifiuta se il file esiste già | Una sola volta, alla prima installazione ([setup.md](setup.md)) |
|
||||
| `scripts/encrypt_master_key.py` | Cifra una xprv incollata (input nascosto) e la scrive in `MASTER_KEY_PATH`, con ripiego su `./data/keys/` se la cartella di `MASTER_KEY_PATH` non esiste. Rifiuta una xpub e un file già presente senza `--overwrite` | Installazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) |
|
||||
| `scripts/decrypt_master_key.py` | Decifra e stampa la xprv, dopo aver chiesto di scrivere `yes`. Cerca il file in `MASTER_KEY_PATH`, poi in `./data/keys/master.xprv.enc` | Backup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) |
|
||||
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un server di riserva, lanciarlo con `ELECTRUM_HOST`/`ELECTRUM_PORT` impostati su quel server |
|
||||
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un altro server, lanciarlo con `-e ELECTRUM_HOST=… -e ELECTRUM_PORT=…` |
|
||||
|
||||
`generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri
|
||||
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`.
|
||||
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. I
|
||||
file scritti hanno i permessi predefiniti del sistema (di norma `0644`) e,
|
||||
nel container, appartengono a root. Dopo B-89 gli script scrivono `0600` e
|
||||
rifiutano una xprv diversa da quella degli indirizzi già nel database
|
||||
([SEC-50](sicurezza.md#sec-50)).
|
||||
*Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`,
|
||||
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`,
|
||||
`scripts/electrum_smoke_test.py`
|
||||
@@ -0,0 +1,127 @@
|
||||
# Piano delle correzioni (revisione del 2026-09-28)
|
||||
|
||||
La revisione della specifica del 2026-09-28 (coin e catena, macchina a stati
|
||||
del denaro, operatività, coerenza dei documenti) ha trovato falle logiche
|
||||
nella struttura del software. Questo documento raccoglie **le decisioni prese**
|
||||
e **l'ordine in cui implementarle**.
|
||||
|
||||
La specifica è già stata aggiornata al comportamento **atteso**. Ogni
|
||||
requisito che il codice non soddisfa ancora è marcato
|
||||
`*Stato:* da implementare (B-nn)` e dice in una riga cosa fa il codice oggi
|
||||
(vedi [processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
|
||||
Il dettaglio tecnico di ogni voce, in inglese, è in [BUGS.md](../BUGS.md).
|
||||
|
||||
## Le decisioni
|
||||
|
||||
1. **Estrazione con commit-reveal.** Il vincitore non deve essere calcolabile
|
||||
da chi produce o trasmette i blocchi: la potenza di calcolo di PLM
|
||||
(~22 TH/s) si può affittare. All'apertura il server si impegna su un
|
||||
segreto pubblicandone l'impronta. Alla chiusura si fissano l'elenco dei
|
||||
partecipanti e l'altezza del blocco. Il seme combina segreto, blocco ed
|
||||
elenco, e il segreto viene rivelato dopo. Un minatore non sa chi vincerebbe
|
||||
e quindi non ha motivo di scartare blocchi. L'operatore non può cambiare
|
||||
il segreto dopo averne pubblicato l'impronta.
|
||||
→ [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), B-73
|
||||
2. **Nel dubbio non si liberano mai i fondi.** Una transazione viene
|
||||
annullata solo con una prova: un rifiuto esplicito della rete, oppure i suoi
|
||||
ingressi spesi da un'altra transazione confermata. Mai per un timeout o per
|
||||
il silenzio di un solo server.
|
||||
→ [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md), B-76, B-77, B-78
|
||||
3. **Un indirizzo del montepremi per ogni round.** Soldi, cronologia e
|
||||
pagamento di un round restano separati da quelli di tutti gli altri round.
|
||||
→ B-80
|
||||
4. **6 conferme per i depositi** (configurabili). Tutte le altre transazioni
|
||||
restano a 1: il deposito è l'unico ingresso che un utente può spendere due
|
||||
volte, perché il resto lo firma solo il server.
|
||||
→ [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito), B-82
|
||||
5. **Le riorganizzazioni della catena si contengono.** Si rilevano, si mette
|
||||
in pausa la lotteria quando sono profonde, e si paga solo ciò che risulta
|
||||
davvero sulla catena.
|
||||
→ B-82, B-85
|
||||
|
||||
**Limite accettato:** un attacco al 51% su PLM resta possibile e riguarda
|
||||
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).
|
||||
|
||||
## Ordine di implementazione
|
||||
|
||||
Le fasi vanno eseguite in ordine: ognuna si appoggia sulle precedenti.
|
||||
Dentro una fase l'ordine è indicativo. Ogni voce è un commit a sé, con il suo
|
||||
test di regressione, secondo [processo-di-modifica.md](processo-di-modifica.md).
|
||||
|
||||
### Fase 1 — Fondamenta
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-88 | Transazioni SQLite vere (`BEGIN IMMEDIATE`, `synchronous=FULL`) | alta |
|
||||
| B-75 | Client Electrum: limite di 16 MiB, risposta troppo grande = errore della richiesta, UTXO sotto la soglia di dust ignorati | critica |
|
||||
| B-97 | Test che esegue le migrazioni e verifica gli indici | media |
|
||||
| B-80 | Indirizzo del montepremi per round | critica |
|
||||
|
||||
### Fase 2 — Nel dubbio non liberare
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-84 | Quorum minimo obbligatorio (almeno 2 server di riserva, almeno 2 risposte) | alta |
|
||||
| B-76 | Invio ambiguo: la riga resta `building`, invio a tutti i server | critica |
|
||||
| B-77 | Tutti i txid della catena di sostituzioni; RBF salvato prima dell'invio | critica |
|
||||
| B-78 | Abbandono solo con prova; le righe `failed` restano sorvegliate | critica |
|
||||
| B-83 | Conferme e reintegri verificati dal quorum (prova merkle) | alta |
|
||||
|
||||
### Fase 3 — Estrazione
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-74 | Header: difficoltà minima, collegamento verificato su ogni salto | critica |
|
||||
| B-85 | Riorganizzazioni seguite, registrate, con pausa se profonde | alta |
|
||||
| B-86 | Rotazione se il server si ferma | alta |
|
||||
| B-73 | Commit-reveal, altezza fissa, conferme sul blocco di estrazione, prova pubblica | critica |
|
||||
| B-81 | `drawing` riprende dopo un errore o un riavvio | critica |
|
||||
|
||||
### Fase 4 — Pagamento
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-79 | Riserva per l'RBF su ogni pagamento | critica |
|
||||
| B-82 | 6 conferme sui depositi, 120 sulle coinbase, verifica prima del pagamento | critica |
|
||||
| B-87 | RBF che non lascia resti sotto la soglia di dust | alta |
|
||||
| B-95 | Parametri del round fotografati all'apertura, tetto al montepremi | media |
|
||||
|
||||
### Fase 5 — Operatività
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-71 | Una sola copia della master key e del database | bassa |
|
||||
| B-72 | `setup.md` senza il falso "in locale o via Docker" | bassa |
|
||||
| B-89 | Verifica della chiave all'avvio | alta |
|
||||
| B-90 | Ripristino da backup sicuro | alta |
|
||||
| B-92 | Readiness, stato Electrum, filtri dell'audit, log | media |
|
||||
| B-93 | Deploy e rollback | media |
|
||||
| B-91 | Container non root, permessi, dipendenze fissate | alta |
|
||||
|
||||
### Fase 6 — Rifiniture
|
||||
|
||||
| Voce | Cosa | Gravità |
|
||||
|---|---|---|
|
||||
| B-94 | Limiti su cambio password, login, bug report; errori strutturati | media |
|
||||
| B-96 | Incoerenze minori (sette voci, un test ciascuna) | media |
|
||||
|
||||
### Fase 7 — Prova su rete reale
|
||||
|
||||
Nessuna delle falle critiche sarebbe stata trovata dai test attuali, che
|
||||
simulano tutto. Prima di aprire al pubblico serve una prova con importi
|
||||
piccoli e guasti provocati di proposito, secondo la lista di
|
||||
[operativita.md](operativita.md#prova-su-rete-reale).
|
||||
|
||||
## Correzioni solo documentali già applicate
|
||||
|
||||
Nella stessa revisione sono state corrette, senza un B-nn perché il codice non
|
||||
cambia:
|
||||
- affermazioni false sul comportamento attuale: timeout trattato come rifiuto,
|
||||
"prima o poi abbandonata", quorum "su ogni decisione", tempi "nel caso
|
||||
peggiore", resto di 294 sat "accelerabile";
|
||||
- due procedure operative pericolose: il ripristino con sblocco di `drawing`
|
||||
(OPS-07) e la rotazione della chiave di cifratura (OPS-22);
|
||||
- puntatori sbagliati e requisiti duplicati in più documenti;
|
||||
- formulazioni vaghe in regole normative.
|
||||
@@ -11,8 +11,9 @@ Ogni regola verificabile ha un **identificativo stabile**, nella forma
|
||||
seguito dai riferimenti all'implementazione e, dove esistono, ai test:
|
||||
|
||||
```markdown
|
||||
- **[RND-11]** Alla scadenza il round passa subito a `closing`: da quel momento
|
||||
nessuna scommessa è più accettata.
|
||||
- **[RND-11]** Al primo passo dello scheduler successivo alla scadenza il round
|
||||
passa a `closing`; le scommesse sono però rifiutate già dall'istante della
|
||||
scadenza ([BET-04]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
```
|
||||
@@ -28,6 +29,41 @@ Regole della convenzione:
|
||||
mai numeri di riga (cambiano a ogni modifica).
|
||||
- Il testo descrittivo che non contiene regole (introduzioni, spiegazioni,
|
||||
esempi) non ha identificativo.
|
||||
- **Un solo proprietario per regola.** Ogni regola ha l'identificativo in un
|
||||
solo documento; gli altri documenti che la citano rimandano a quell'ID
|
||||
invece di ripeterla con un ID proprio. Il documento di dominio (round,
|
||||
scommesse, transazioni…) è proprietario del comportamento, `DAT` dello
|
||||
schema, `SEC` delle soglie di sicurezza.
|
||||
- **Niente formulazioni vaghe nelle regole.** "Circa", "normalmente", "prima o
|
||||
poi" non compaiono in un requisito: si scrive la condizione esatta, con il
|
||||
nome della costante.
|
||||
|
||||
### Requisiti non ancora implementati
|
||||
|
||||
La specifica può essere più avanti del codice: è il caso normale fra il passo 1
|
||||
e il passo 3 del ciclo qui sotto. Un requisito che il codice non soddisfa
|
||||
ancora porta, subito dopo i riferimenti, una riga di stato e una che dice cosa
|
||||
fa il codice oggi:
|
||||
|
||||
```markdown
|
||||
- **[RND-33]** L'altezza del blocco di estrazione è fissata all'ingresso in
|
||||
`drawing` come `tip_at_close + 1` e non cambia più.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* si usa la punta vista al primo
|
||||
controllo utile, che può essere un blocco successivo.
|
||||
```
|
||||
|
||||
- *Codice* indica dove andrà l'implementazione (il file o la funzione
|
||||
esistente più vicina); *Test* è `—` finché il test non esiste.
|
||||
- Il B-nn rimanda alla voce di [BUGS.md](../BUGS.md) che descrive la
|
||||
correzione; l'ordine di implementazione è in
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- Quando il codice viene implementato, nello stesso commit si tolgono le righe
|
||||
*Stato* e *Oggi* e si completano *Codice* e *Test*. `git grep "da
|
||||
implementare"` elenca in ogni momento tutto ciò che manca.
|
||||
- Un requisito modificato (non nuovo) mantiene il suo identificativo: il testo
|
||||
descrive il comportamento atteso, *Oggi* quello attuale.
|
||||
|
||||
### Prefissi
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ e saldo), secondo la convenzione di
|
||||
descritti in dettaglio in [api.md](api.md); hashing, token e limiti di
|
||||
frequenza in [sicurezza.md](sicurezza.md); le colonne delle tabelle in
|
||||
[modello-dati.md](modello-dati.md); gli eventi di audit in
|
||||
[audit-log.md](audit-log.md).
|
||||
[audit-log.md](audit-log.md). 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* (vedi
|
||||
[piano-correzioni.md](piano-correzioni.md)).
|
||||
|
||||
## Registrazione
|
||||
|
||||
@@ -23,7 +26,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
|
||||
4. si iscrive, via Electrum, alle notifiche di quell'indirizzo. Questo passo
|
||||
è "al meglio": se fallisce, l'indirizzo viene comunque ripreso alla
|
||||
riconnessione successiva o al giro periodico di `DepositReconciler`
|
||||
(ogni 5 minuti).
|
||||
([DEP-10](#dep-10)).
|
||||
|
||||
- **[REG-01]** Lo username è lungo da 3 a 32 caratteri, composto solo da
|
||||
lettere ASCII, cifre, `_`, `.` e `-`; la password è lunga da 8
|
||||
@@ -52,7 +55,8 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
|
||||
riprova con un indice ricalcolato fino a 5 volte (`_MAX_REGISTER_RETRIES`),
|
||||
poi la richiesta fallisce con `derivation_index_conflict` (HTTP 409).
|
||||
*Codice:* `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` ·
|
||||
*Test:* `tests/unit/test_hd.py`
|
||||
*Test:* `tests/unit/test_hd.py` (solo la derivazione dell'indirizzo:
|
||||
nessun test copre i tentativi ripetuti né `derivation_index_conflict`)
|
||||
- **[REG-05]** L'indirizzo è **permanente** e unico per utente, ed è allo
|
||||
stesso tempo indirizzo di deposito, destinazione delle vincite e
|
||||
destinazione del resto di ogni scommessa e prelievo: non esiste un
|
||||
@@ -73,51 +77,81 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
|
||||
periodico di [DEP-10](#dep-10).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[REG-08]** La registrazione è limitata a **5 account all'ora per
|
||||
indirizzo IP**. Il limite è verificato prima di calcolare l'hash, e conta
|
||||
solo le registrazioni riuscite; oltre il limite la risposta è
|
||||
`rate_limited` (HTTP 429) con `retry_after_seconds`. I dettagli sono in
|
||||
[sicurezza.md](sicurezza.md).
|
||||
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
- ~~[REG-08]~~ Assorbito da [SEC-31](sicurezza.md#limiti-di-frequenza):
|
||||
la quota di registrazioni per IP (soglia, finestra, momento del controllo,
|
||||
risposta `rate_limited`) è una soglia di sicurezza e ha lì il suo unico
|
||||
proprietario; qui non resta nulla di specifico del dominio.
|
||||
- **[REG-15]** **Indici già usati dopo un ripristino.** Un database
|
||||
ripristinato da un backup non conosce gli utenti registrati dopo il backup,
|
||||
e riassegnerebbe i loro indici — e quindi i loro indirizzi, con i fondi che
|
||||
contengono — a utenti nuovi. Per questo, all'avvio, appena la connessione
|
||||
Electrum è disponibile, si controllano i **20 indici successivi** al
|
||||
massimo indice assegnato (da 0 a 19 se non c'è alcun utente): per ciascun
|
||||
indirizzo si chiede la cronologia (`blockchain.scripthash.get_history`) al
|
||||
server attivo e a ogni server del quorum, e basta che **uno** riporti una
|
||||
transazione perché l'indirizzo conti come usato. Se almeno un indirizzo è
|
||||
usato si scrive `restore_gap_detected` (indici e indirizzi con
|
||||
cronologia) e la registrazione viene rifiutata con
|
||||
`registration_unavailable` (HTTP 503) finché, a un avvio successivo, il
|
||||
controllo non trova più alcun indirizzo usato: l'operatore deve prima
|
||||
ricostruire gli utenti mancanti (procedura in
|
||||
[operativita.md](operativita.md#ripristino), B-90). La registrazione è
|
||||
rifiutata allo stesso modo finché il controllo non è stato completato.
|
||||
*Codice:* `app/main.py:lifespan`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* —
|
||||
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo; dopo un
|
||||
ripristino il prossimo utente riceve il primo indice non presente nel
|
||||
database, anche se il suo indirizzo ha già ricevuto fondi.
|
||||
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
|
||||
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
|
||||
transazione che crea l'utente.
|
||||
*Codice:* `app/auth/routes.py:register` · *Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
|
||||
evento di audit.
|
||||
|
||||
### Sessione e password
|
||||
|
||||
Il login (`POST /auth/login`) restituisce un token JWT valido 24 ore. Cambiare
|
||||
la password (dal Profilo) o farsela reimpostare dall'operatore invalida
|
||||
**tutte** le sessioni aperte in precedenza.
|
||||
Il login (`POST /auth/login`) restituisce un token di sessione. Cambiare la
|
||||
password (dal Profilo) o farsela reimpostare dall'operatore invalida
|
||||
**tutte** le sessioni aperte in precedenza. Formato, durata e revoca del token
|
||||
sono regole di sicurezza ([SEC-21, SEC-22](sicurezza.md#token-di-sessione-jwt));
|
||||
il freno sui tentativi di login falliti è
|
||||
[SEC-30](sicurezza.md#limiti-di-frequenza).
|
||||
|
||||
- **[REG-09]** Il login confronta lo username senza distinguere maiuscole e
|
||||
minuscole. Username inesistente e password sbagliata producono la stessa
|
||||
risposta, `invalid_credentials` (HTTP 401), così che non si possa scoprire
|
||||
quali account esistono. Una risposta riuscita contiene `access_token` e
|
||||
l'indirizzo dell'utente.
|
||||
*Codice:* `app/auth/routes.py:login` ·
|
||||
l'indirizzo dell'utente. Username e password del login sono lunghi al
|
||||
massimo 256 caratteri ciascuno; oltre, la richiesta è rifiutata con HTTP 422
|
||||
prima di qualunque verifica.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
- **[REG-10]** I tentativi di login falliti sono frenati sia per username sia
|
||||
per IP; durante il blocco la risposta è `rate_limited` (HTTP 429) con
|
||||
`retry_after_seconds`. Un login riuscito azzera il contatore dello username,
|
||||
non quello dell'IP. Soglie e tempi sono in [sicurezza.md](sicurezza.md).
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
- **[REG-11]** Il token di sessione scade dopo 24 ore (`jwt_expire_minutes`)
|
||||
e porta con sé la versione del token dell'utente (`token_version`). Un token
|
||||
la cui versione non coincide più con quella dell'utente, oppure scaduto,
|
||||
malformato o di un utente inesistente, è rifiutato con `session_expired`
|
||||
(HTTP 401) dagli endpoint autenticati, e trattato come "non autenticato"
|
||||
dagli endpoint accessibili anche senza login. Un token emesso prima che la
|
||||
versione esistesse vale come versione 0.
|
||||
*Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token`, `app/auth/dependencies.py:get_current_user`, `app/auth/dependencies.py:get_optional_user` ·
|
||||
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per la lunghezza massima · *Oggi:* i campi
|
||||
del login non hanno limiti di lunghezza.
|
||||
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
|
||||
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
|
||||
specifico del dominio oltre a ciò che SEC-30 già dice.
|
||||
- ~~[REG-11]~~ Assorbito da [SEC-22](sicurezza.md#token-di-sessione-jwt):
|
||||
scadenza, versione del token e rifiuto con `session_expired` sono regole di
|
||||
sicurezza (SEC-21, SEC-22, SEC-23) e non resta nulla di specifico del
|
||||
dominio.
|
||||
- **[REG-12]** Il cambio password (`POST /users/me/change-password`, dal
|
||||
Profilo) richiede la password attuale — altrimenti `current_password_incorrect`
|
||||
(HTTP 401) — e una nuova password di almeno 8 caratteri — altrimenti
|
||||
`password_too_short` (HTTP 400). Se riesce, incrementa `token_version`,
|
||||
invalidando **tutte** le sessioni aperte, compresa quella della richiesta, e
|
||||
`password_too_short` (HTTP 400). Entrambi i campi sono lunghi al massimo
|
||||
256 caratteri, altrimenti HTTP 422. I tentativi con password attuale
|
||||
sbagliata sono frenati come il login (soglie in
|
||||
[sicurezza.md](sicurezza.md#limiti-di-frequenza), B-94). Se riesce,
|
||||
incrementa `token_version`, invalidando **tutte** le sessioni aperte,
|
||||
compresa quella della richiesta, scrive l'evento `password_changed` e
|
||||
restituisce un token nuovo perché la sessione corrente continui senza nuovo
|
||||
login.
|
||||
login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md)
|
||||
e [api.md](api.md) vi rimandano.
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per lunghezza massima, freno ed evento
|
||||
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
|
||||
tentativi, nessun evento di audit.
|
||||
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
|
||||
L'unica strada è il reset dall'operatore
|
||||
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
|
||||
@@ -130,63 +164,116 @@ la password (dal Profilo) o farsela reimpostare dall'operatore invalida
|
||||
|
||||
### Il conto del montepremi
|
||||
|
||||
Oltre agli indirizzi utente, dalla stessa master key si deriva un indirizzo
|
||||
riservato al **montepremi**. Tutte le scommesse vengono inviate lì, e i
|
||||
pagamenti ai vincitori sono firmati con la sua chiave.
|
||||
Oltre agli indirizzi utente, dalla stessa master key si derivano gli indirizzi
|
||||
del **montepremi**: uno per ogni round, su cui arrivano le scommesse di quel
|
||||
round e da cui parte il suo pagamento, e un indirizzo di **riserva** che
|
||||
riceve la riserva per l'RBF di ogni pagamento
|
||||
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento), B-79).
|
||||
|
||||
- **[REG-14]** L'indirizzo del montepremi è il P2WPKH del ramo `1/0`
|
||||
dell'account (`m/84'/746'/0'/1/0`), mentre gli utenti stanno sul ramo `0`.
|
||||
Le sue monete non sono registrate tra le monete degli utenti: il pagamento
|
||||
le legge direttamente dalla rete (vedi
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)).
|
||||
- **[REG-14]** Gli utenti stanno sul ramo `0` dell'account
|
||||
(`m/84'/746'/0'/0/<indice>`). Il montepremi di un round è il P2WPKH di
|
||||
`m/84'/746'/0'/2/<round_id>`, salvato sul round all'apertura; l'indirizzo
|
||||
di riserva è il P2WPKH di `m/84'/746'/0'/1/0`, che è anche il montepremi
|
||||
dei round aperti prima di questa modifica. Le loro monete non sono
|
||||
registrate tra le monete degli utenti: il pagamento le legge dalla rete e
|
||||
le verifica con il quorum
|
||||
([ELC-27](connessione-electrum.md#5-il-quorum)).
|
||||
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` ·
|
||||
*Test:* `tests/unit/test_pending_address.py`
|
||||
*Stato:* da implementare (B-80) · *Oggi:* esiste un solo indirizzo del
|
||||
montepremi, `m/84'/746'/0'/1/0`, per tutti i round.
|
||||
|
||||
## Deposito
|
||||
|
||||
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno. Non c'è
|
||||
un importo minimo.
|
||||
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno.
|
||||
|
||||
Un **deposito** è una moneta arrivata all'indirizzo di un utente da una
|
||||
transazione che la piattaforma non ha costruito. Le monete prodotte dalle
|
||||
transazioni della piattaforma — il resto di scommesse e prelievi, le
|
||||
vincite — arrivano allo stesso indirizzo e passano per lo stesso codice, ma
|
||||
con la soglia di conferme delle transazioni della piattaforma (1): il
|
||||
deposito è l'unico ingresso che un utente può spendere due volte
|
||||
([DEC-06](decisioni-e-limiti.md)).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Arriva una notifica Electrum<br/>per l'indirizzo, oppure<br/>il giro periodico di controllo"] --> B["Si legge l'elenco delle<br/>monete (UTXO) dell'indirizzo<br/>dal server attivo"]
|
||||
B --> C{"Moneta nuova<br/>con almeno 1 conferma?"}
|
||||
C -- "No" --> Z["Nulla da fare"]
|
||||
C -- "Sì" --> D{"La maggioranza degli<br/>altri server conferma stessa<br/>moneta, stesso importo?"}
|
||||
D -- "No / non rispondono" --> R["Accredito rinviato<br/>al prossimo controllo"]
|
||||
A["Notifica Electrum per l'indirizzo,<br/>(re)iscrizione, oppure<br/>giro periodico (DEP-09)"] --> L["Aggiornamenti dello stesso<br/>utente in fila (DEP-15)"]
|
||||
L --> B["Si legge l'elenco delle<br/>monete (UTXO) dell'indirizzo<br/>dal server attivo"]
|
||||
B --> DU{"Moneta sotto<br/>DUST_LIMIT_SATS?"}
|
||||
DU -- "Sì" --> Z["Ignorata (DEP-01)"]
|
||||
DU -- "No" --> C{"Moneta nuova<br/>e confermata?"}
|
||||
C -- "No" --> Z2["Nulla da fare"]
|
||||
C -- "Sì" --> K["Soglia (DEP-03):<br/>1 se la transazione è della piattaforma,<br/>120 se coinbase,<br/>altrimenti deposit_confirmations"]
|
||||
K --> KK{"Conferme ≥ soglia?"}
|
||||
KK -- "No" --> P["Mostrata come in attesa<br/>nel saldo mostrato (DEP-14)"]
|
||||
KK -- "Sì" --> D{"Il quorum degli altri<br/>server conferma moneta,<br/>importo e conferme?"}
|
||||
D -- "No / meno di 2 risposte" --> R["Accredito rinviato<br/>al prossimo controllo"]
|
||||
D -- "Sì" --> E["Moneta registrata,<br/>saldo ricalcolato<br/>(deposit_credited)"]
|
||||
B --> F{"Una moneta nota<br/>non spesa è sparita?"}
|
||||
F -- "Sì, e la maggioranza degli<br/>altri server la conferma spesa" --> G["Segnata come spesa<br/>fuori dalla piattaforma<br/>(utxo_spent_externally)"]
|
||||
F -- "Sì, e il quorum<br/>la conferma spesa" --> G["Segnata come spesa<br/>fuori dalla piattaforma<br/>(utxo_spent_externally)"]
|
||||
B --> H{"Una moneta data per spesa<br/>fuori è ricomparsa?"}
|
||||
H -- "Sì" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
|
||||
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
|
||||
```
|
||||
|
||||
Le vincite arrivano allo stesso modo: il pagamento del round è una normale
|
||||
transazione verso l'indirizzo del vincitore, e viene accreditato come un
|
||||
deposito. Lo stesso vale per il resto di scommesse e prelievi, che torna
|
||||
all'indirizzo dell'utente.
|
||||
*Comportamento atteso.* Oggi ogni moneta confermata si accredita a 1
|
||||
conferma, di qualunque importo e anche se coinbase; il reintegro non è
|
||||
corroborato; gli aggiornamenti dello stesso utente non sono in fila; le
|
||||
monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
|
||||
- **[DEP-01]** Non c'è un importo minimo di deposito.
|
||||
*Codice:* `app/deposits/service.py:credit_confirmed_utxos`
|
||||
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
|
||||
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
|
||||
viene mai accreditata né mostrata, e non entra in alcun altro controllo di
|
||||
questo documento. Non è spendibile in modo economico e, se accreditata,
|
||||
farebbe crescere senza limite il lavoro di ogni aggiornamento; chiunque
|
||||
può inviarne a qualunque indirizzo pubblico.
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:credit_confirmed_utxos`, `app/wallet/psbt_builder.py:DUST_LIMIT_SATS` · *Test:* —
|
||||
*Stato:* da implementare (B-75) · *Oggi:* si accredita una moneta di
|
||||
qualunque importo.
|
||||
- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco
|
||||
delle monete non spese (`listunspent`) dal server Electrum attivo ed esegue,
|
||||
in quest'ordine: individuazione degli accrediti nuovi, reintegro delle
|
||||
monete ricomparse, individuazione delle monete sparite; poi corrobora
|
||||
accrediti e sparizioni presso gli altri server e solo dopo scrive il
|
||||
risultato. Nessuna sessione del database resta aperta durante le chiamate
|
||||
di rete.
|
||||
delle monete non spese (`listunspent`) dal server Electrum attivo e procede
|
||||
in tre fasi. **Lettura**, senza scrivere nulla: individuazione degli
|
||||
accrediti nuovi, delle monete ricomparse e delle monete sparite.
|
||||
**Corroborazione** presso gli altri server di accrediti
|
||||
([ELC-17](connessione-electrum.md#5-il-quorum)), reintegri
|
||||
([ELC-25](connessione-electrum.md#5-il-quorum)) e sparizioni
|
||||
([ELC-16](connessione-electrum.md#5-il-quorum)). **Scrittura** dei soli
|
||||
casi corroborati, ricontrollando lo stato di ogni moneta al salvataggio.
|
||||
Nessuna sessione del database resta aperta durante le chiamate di rete.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[DEP-03]** **Una conferma basta.** Una moneta è candidata all'accredito
|
||||
*Stato:* da implementare (B-83) · *Oggi:* il reintegro delle monete
|
||||
ricomparse viene **scritto e committato già nella fase di lettura**, prima
|
||||
di qualunque corroborazione; solo accrediti e sparizioni sono corroborati
|
||||
prima di essere scritti.
|
||||
- **[DEP-03]** **Soglia di conferme.** Una moneta è candidata all'accredito
|
||||
solo se è confermata (altezza maggiore di 0: le monete ancora in mempool
|
||||
sono ignorate) e non è già registrata. Il rischio di una riorganizzazione
|
||||
della catena a 1 conferma è accettato consapevolmente; non c'è logica di
|
||||
annullamento (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
|
||||
sono ignorate), non è già registrata e ha raggiunto la sua soglia di
|
||||
conferme. Le conferme sono `punta − altezza + 1`, sulla punta validata del
|
||||
listener ([connessione-electrum.md](connessione-electrum.md#4-header-validati-non-creduti)).
|
||||
La soglia è:
|
||||
- **1** se la transazione che l'ha creata è una transazione della
|
||||
piattaforma (uno qualunque dei txid di una riga di
|
||||
`pending_transactions`, compresi quelli sostituiti da un RBF,
|
||||
[modello-dati.md](modello-dati.md) B-77): resto di scommesse e prelievi,
|
||||
vincite;
|
||||
- **120** (la maturità delle coinbase di PLM, `COINBASE_MATURITY` in
|
||||
`palladiumcore/src/consensus/consensus.h`) se la transazione è una
|
||||
coinbase, cioè il suo unico ingresso ha l'outpoint nullo. Lo si stabilisce
|
||||
dalla transazione grezza (`blockchain.transaction.get`), accettata solo se
|
||||
il suo hash coincide con il txid;
|
||||
- **`deposit_confirmations`** (`round_config`, predefinito 6,
|
||||
[modello-dati.md](modello-dati.md) B-82) in ogni altro caso.
|
||||
|
||||
Una moneta accreditata e poi sparita per una riorganizzazione più profonda
|
||||
della soglia segue la strada delle monete spese altrove
|
||||
([DEP-06], [DEP-07]); non c'è altra logica di annullamento.
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* **una conferma basta** per ogni
|
||||
moneta, deposito o coinbase che sia.
|
||||
- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo,
|
||||
il server chiede agli *altri* server Electrum configurati se quella moneta
|
||||
esiste davvero, con quell'importo, confermata
|
||||
esiste davvero, con quell'importo e con le conferme richieste
|
||||
([ELC-17](connessione-electrum.md#5-il-quorum)). Un server malevolo non può
|
||||
quindi inventarsi un deposito. Se la verifica non riesce l'accredito non è
|
||||
perso, solo rimandato: la moneta viene riproposta all'aggiornamento
|
||||
@@ -210,8 +297,8 @@ all'indirizzo dell'utente.
|
||||
incompleta, non come una spesa di tutto.
|
||||
*Codice:* `app/deposits/service.py:find_utxos_missing_from` ·
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che la
|
||||
maggioranza degli altri server conferma che la moneta non è più non spesa
|
||||
- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che il quorum
|
||||
degli altri server conferma che la moneta non è più non spesa
|
||||
([ELC-16](connessione-electrum.md#5-il-quorum)). Al salvataggio si
|
||||
ricontrolla che la moneta sia ancora non spesa (nel frattempo potrebbe
|
||||
averla spesa la piattaforma); la si segna con il marcatore di spesa esterna,
|
||||
@@ -220,12 +307,18 @@ all'indirizzo dell'utente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/deposits/service.py:mark_utxos_spent_externally` ·
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
|
||||
- **[DEP-08]** **Reintegro.** Una moneta segnata come spesa fuori dalla
|
||||
piattaforma che ricompare nell'elenco viene reintegrata: il marcatore è
|
||||
piattaforma che ricompare nell'elenco viene reintegrata solo dopo che il
|
||||
quorum degli altri server la conferma non spesa, con lo stesso importo e
|
||||
confermata, come per un accredito
|
||||
([ELC-25](connessione-electrum.md#5-il-quorum)). Al salvataggio si
|
||||
ricontrolla che porti ancora il marcatore di spesa esterna; il marcatore è
|
||||
rimosso, si scrive `utxo_external_spend_reinstated`, si ricalcola il saldo e
|
||||
si invia una notifica SSE. Il reintegro si basa sulla sola risposta del
|
||||
server attivo, senza corroborazione.
|
||||
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
|
||||
si invia una notifica SSE. Se la verifica non riesce la moneta resta fuori
|
||||
dal saldo e viene riproposta all'aggiornamento successivo.
|
||||
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
*Stato:* da implementare (B-83) · *Oggi:* il reintegro si basa sulla sola
|
||||
risposta del server attivo, senza corroborazione.
|
||||
- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il
|
||||
server notifica un cambiamento su un indirizzo osservato, alla
|
||||
(re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la
|
||||
@@ -234,13 +327,36 @@ all'indirizzo dell'utente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||||
*Test:* `tests/unit/test_deposit_reconcile.py`
|
||||
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
|
||||
attende 300 secondi, poi riaggiorna **tutti** gli utenti, non solo quelli
|
||||
che risultano non iscritti: serve a recuperare un'iscrizione persa
|
||||
silenziosamente. Il giro salta se non c'è connessione, si interrompe se la
|
||||
connessione cade a metà (la reiscrizione alla riconnessione copre tutti), e
|
||||
l'errore su un utente viene registrato senza fermare gli altri.
|
||||
riaggiorna **tutti** gli utenti, non solo quelli che risultano non
|
||||
iscritti: serve a recuperare un'iscrizione persa silenziosamente. Prima di
|
||||
ogni giro, incluso il primo, attende 300 s (`_SWEEP_INTERVAL_SECONDS`)
|
||||
dalla fine del giro precedente (per il primo, dall'avvio); i giri sono
|
||||
seriali, e dentro un giro gli utenti sono aggiornati uno alla volta. Il giro
|
||||
salta se non c'è connessione, si interrompe se la connessione cade a metà
|
||||
(la reiscrizione alla riconnessione copre tutti), e l'errore su un utente
|
||||
viene registrato senza fermare gli altri.
|
||||
*Codice:* `app/deposits/reconcile.py:DepositReconciler.run`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||||
*Test:* `tests/unit/test_deposit_reconcile.py`
|
||||
- **[DEP-15]** Gli aggiornamenti dello **stesso** utente sono serializzati:
|
||||
una notifica, la reiscrizione e il giro periodico possono chiederli insieme,
|
||||
ma ne gira uno alla volta per utente, in ordine di arrivo. Il blocco è
|
||||
proprio dell'aggiornamento e distinto da quello per-utente di scommesse e
|
||||
prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una
|
||||
corroborazione in corso non ritarda una scommessa. Utenti diversi si
|
||||
aggiornano in parallelo come oggi.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso
|
||||
utente possono girare insieme e tentare di accreditare la stessa moneta.
|
||||
- **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la
|
||||
connessione Electrum: viene registrato nei log e l'utente viene ripreso
|
||||
all'aggiornamento successivo. In particolare una violazione dell'unicità
|
||||
txid/posizione all'accredito ([DEP-05]) vale come "moneta già registrata":
|
||||
si annulla la transazione del database di quell'accredito e si prosegue.
|
||||
Fa eccezione solo la caduta della connessione stessa, che termina la
|
||||
sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da
|
||||
`refresh_user` a `_consume_scripthash` e termina la sessione Electrum.
|
||||
|
||||
## Il saldo mostrato
|
||||
|
||||
@@ -248,19 +364,23 @@ Scommesse e prelievi spendono monete **intere**: per pagare 10 PLM con una
|
||||
moneta da 50, la moneta da 50 viene spesa tutta e 40 tornano all'utente come
|
||||
resto (nella scommessa la commissione è dedotta dai 10 PLM, vedi
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede)), in una nuova
|
||||
moneta che però è confermata solo al blocco successivo.
|
||||
moneta che però è confermata solo al blocco successivo. Allo stesso modo un
|
||||
deposito resta non spendibile finché non raggiunge la sua soglia di conferme
|
||||
([DEP-03]).
|
||||
|
||||
Per questo esistono due saldi, entrambi restituiti da `GET /users/me`:
|
||||
|
||||
| Campo | Cosa conta | A cosa serve |
|
||||
|---|---|---|
|
||||
| `balance_sats` | Solo monete confermate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa |
|
||||
| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso | È ciò che l'interfaccia mostra |
|
||||
| `balance_sats` | Solo monete accreditate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa |
|
||||
| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso **più** i depositi confermati ma sotto la soglia | È ciò che l'interfaccia mostra |
|
||||
|
||||
Senza il secondo, subito dopo una scommessa l'utente vedrebbe sparire l'intero
|
||||
resto (spesso molto più dei 10 PLM giocati) fino alla conferma. L'interfaccia
|
||||
mostra il saldo in **verde** quando tutto è confermato e in **ambra** finché
|
||||
c'è qualcosa in corso (`has_pending`).
|
||||
resto (spesso molto più dei 10 PLM giocati) fino alla conferma, e un
|
||||
deposito resterebbe invisibile finché non raggiunge `deposit_confirmations`
|
||||
conferme. L'interfaccia mostra il saldo
|
||||
in **verde** quando tutto è confermato e in **ambra** finché c'è qualcosa in
|
||||
corso (`has_pending`).
|
||||
|
||||
- **[DEP-11]** Il saldo confermato (`balance_sats`, colonna
|
||||
`cached_balance_sats`) è la somma delle monete registrate dell'utente non
|
||||
@@ -269,19 +389,40 @@ c'è qualcosa in corso (`has_pending`).
|
||||
*Codice:* `app/wallet/balance.py:recompute_balance` ·
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
- **[DEP-12]** Il saldo mostrato (`pending_balance_sats`) è il saldo
|
||||
confermato più le uscite verso l'indirizzo dell'utente di ogni sua
|
||||
confermato più: (a) le uscite verso l'indirizzo dell'utente di ogni sua
|
||||
scommessa o prelievo in stato `building` o `pending`, lette dalla
|
||||
transazione corrente (aggiornata a ogni RBF). Un'uscita già registrata come
|
||||
moneta (perché il resto è stato accreditato prima che la transazione
|
||||
risultasse confermata) non viene contata due volte. `has_pending` è vero
|
||||
finché esiste almeno una di queste transazioni.
|
||||
transazione corrente (aggiornata a ogni RBF); (b) i depositi in attesa di
|
||||
[DEP-14]. Un'uscita già registrata come moneta (perché il resto è stato
|
||||
accreditato prima che la transazione risultasse confermata) non viene
|
||||
contata due volte. `has_pending` è vero finché esiste almeno una
|
||||
transazione di (a) o un deposito di (b).
|
||||
*Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` ·
|
||||
*Test:* `tests/unit/test_balance.py`
|
||||
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete confermate e
|
||||
*Stato:* da implementare (B-82), per la parte (b) · *Oggi:* conta solo la
|
||||
parte (a).
|
||||
- **[DEP-14]** **Depositi in attesa.** Una moneta sopra la soglia di polvere,
|
||||
confermata (altezza maggiore di 0), non ancora registrata e con meno
|
||||
conferme della sua soglia ([DEP-03]) è un *deposito in attesa*. Il listener
|
||||
tiene, per ogni utente, l'insieme dei depositi in attesa visti all'ultimo
|
||||
aggiornamento, sostituendolo a ogni aggiornamento: un deposito sparito
|
||||
dall'elenco (ad esempio per una riorganizzazione) smette di essere mostrato,
|
||||
uno accreditato passa nel saldo confermato. L'insieme sta solo in memoria —
|
||||
non sposta denaro e non è spendibile, quindi non è corroborato — e dopo un
|
||||
riavvio si ricostruisce alla prima reiscrizione ([ELC-18](connessione-electrum.md#riconnessione)).
|
||||
Le monete ancora in mempool non sono depositi in attesa.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/wallet/balance.py:compute_pending_balance` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* con la soglia a 1 conferma non
|
||||
esistono depositi in attesa; una moneta in mempool non compare in nessun
|
||||
saldo.
|
||||
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete accreditate e
|
||||
non spese: il saldo mostrato è informativo, non spendibile. Se un utente
|
||||
chiede un prelievo coperto dal saldo mostrato ma non ancora da quello
|
||||
confermato, riceve l'errore `balance_pending_confirmation` ("attendi la
|
||||
conferma") invece di un generico "saldo insufficiente" che contraddirebbe
|
||||
ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo)).
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
chiede un prelievo o una scommessa coperti dal saldo mostrato ma non ancora
|
||||
da quello confermato, riceve l'errore `balance_pending_confirmation`
|
||||
("attendi la conferma") invece di un generico "saldo insufficiente" che
|
||||
contraddirebbe ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo); per
|
||||
la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96).
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il
|
||||
prelievo distingue i due casi; una scommessa coperta solo dal saldo
|
||||
mostrato riceve `insufficient_balance`.
|
||||
+201
-80
@@ -29,124 +29,206 @@ Prima dei controlli valgono due condizioni di contorno:
|
||||
- **[BET-01]** Se il server non è connesso alla rete, la scommessa è
|
||||
rifiutata con `network_unavailable` (HTTP 503) prima di qualunque altro
|
||||
controllo.
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`
|
||||
*Codice:* `app/api/routes/bets.py:create_bet` ·
|
||||
*Test:* — (nessun test verifica `network_unavailable`)
|
||||
- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio,
|
||||
avviene tenendo il **blocco per-utente**, lo stesso usato dal prelievo
|
||||
([WDR-01](#prelievo)).
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire`
|
||||
([WDR-01](#prelievo)). Il client della rete usato per la scommessa
|
||||
(`listener.client`) viene letto **dopo** aver acquisito il blocco, così
|
||||
un'attesa sul blocco durante una riconnessione non fa usare una connessione
|
||||
già chiusa; il controllo di [BET-01] si fa su quel valore.
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
|
||||
controllato prima di acquisire il blocco.
|
||||
|
||||
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
|
||||
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
|
||||
rifiutata con il codice indicato (HTTP 400), prima che si muova qualunque
|
||||
cifra:
|
||||
cifra. Il **costo** è quello del round ([BET-21]):
|
||||
|
||||
| ID | Condizione | Codice di errore |
|
||||
|---|---|---|
|
||||
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-03](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
|
||||
| **[BET-04]** | Il round è in stato `open` e la sua scadenza non è ancora passata | `round_closing` |
|
||||
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-04](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
|
||||
| **[BET-04]** | Il round è in stato `open` e la sua **scadenza** — apertura più la durata fotografata all'apertura (`opened_at + duration_seconds`), non lo stato nel database — non è ancora passata | `round_closing` |
|
||||
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
|
||||
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
|
||||
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa (`bet_amount_sats`) | `insufficient_balance` (parametro `required_sats`) |
|
||||
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande | `too_many_inputs` (parametro `max_inputs`) |
|
||||
| **[BET-09]** | Dopo la scommessa resta un resto di almeno 294 sat (`DUST_LIMIT_SATS`) | `balance_leaves_no_change` (parametro `required_extra_sats`) |
|
||||
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. *Stato:* da implementare (B-95) · *Oggi:* il tetto non esiste | `round_full` |
|
||||
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa |
|
||||
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) |
|
||||
| **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) |
|
||||
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet`, `app/rounds/service.py:round_accepts_bets`,
|
||||
`app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
|
||||
(nessun test verifica `no_round_open`)
|
||||
|
||||
La **scadenza** è quella del round stesso (apertura + durata fissata
|
||||
all'apertura), non il suo stato nel database: lo scheduler controlla ogni 5
|
||||
secondi, quindi per qualche istante il round potrebbe risultare ancora
|
||||
"aperto" oltre la scadenza.
|
||||
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-21]** Il **costo** di una scommessa è il `bet_amount_sats`
|
||||
fotografato sul round alla sua apertura
|
||||
([RND-05](ciclo-di-vita-del-round.md#0--apertura)), non il valore corrente
|
||||
di `round_config`: una modifica da `/admin` vale dal round successivo.
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-95) · *Oggi:* il costo è letto da
|
||||
`round_config` a ogni scommessa, quindi una modifica vale subito anche per
|
||||
il round in corso.
|
||||
- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della
|
||||
richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la
|
||||
transazione. L'iscrizione al round viene poi salvata nella stessa
|
||||
transazione del database di un *compare-and-set* sulla riga del round
|
||||
transazione del database — aperta con `BEGIN IMMEDIATE`
|
||||
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia))
|
||||
— di un *compare-and-set* sulla riga del round
|
||||
(`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in
|
||||
quell'istante esatto, o il database segnala un conflitto di scrittura, tutto
|
||||
viene annullato prima dell'invio e la scommessa è rifiutata con
|
||||
`round_closing`. Così nessuna scommessa può finire nel montepremi senza
|
||||
essere inclusa nell'estrazione.
|
||||
quell'istante esatto tutto viene annullato prima dell'invio e la scommessa è
|
||||
rifiutata con `round_closing`. Così nessuna scommessa può finire nel
|
||||
montepremi senza essere inclusa nell'estrazione. Un `OperationalError` in
|
||||
questo punto non è un conflitto di scrittura (SQLite non ne produce): è il
|
||||
blocco in scrittura non ottenuto entro 5 secondi (`database is locked`).
|
||||
Anche in quel caso tutto viene annullato prima dell'invio, ma la risposta è
|
||||
`service_busy` (HTTP 503), perché il round non è necessariamente in
|
||||
chiusura.
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-88) · *Oggi:* la transazione si apre con la
|
||||
prima scrittura, non con `BEGIN IMMEDIATE`; un `OperationalError` viene
|
||||
trattato come un conflitto di scrittura e risponde `round_closing`.
|
||||
- **[BET-22]** `MAX_PARTICIPANTS_PER_ROUND` è minore di
|
||||
`MAX_PAYOUT_TX_INPUTS`. Il pagamento spende una moneta del montepremi per
|
||||
ogni scommessa, e una singola transazione di pagamento può spenderne al
|
||||
massimo 500; bloccare il 401° giocatore all'ingresso ([BET-06]) fa sì che
|
||||
il numero di monete non impedisca mai di pagare un round. La differenza
|
||||
lascia spazio ad altre monete presenti sull'indirizzo del montepremi del
|
||||
round (pagamenti arrivati da fuori).
|
||||
*Codice:* `app/wallet/psbt_builder.py:MAX_PARTICIPANTS_PER_ROUND`, `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_bets.py`
|
||||
|
||||
Il limite di **400 partecipanti** esiste perché il pagamento deve spendere una
|
||||
moneta del montepremi per ogni scommessa, e una singola transazione di
|
||||
pagamento può contenerne al massimo 500 (`MAX_PAYOUT_TX_INPUTS`); la
|
||||
differenza lascia spazio ai resti accumulati nel montepremi dai round
|
||||
precedenti. Bloccare il 401° giocatore all'ingresso garantisce che ogni round
|
||||
si possa sempre pagare.
|
||||
Oggi il montepremi è un solo indirizzo per tutti i round, e la differenza fra
|
||||
400 e 500 copre anche i resti accumulati dai pagamenti precedenti; con un
|
||||
indirizzo per round
|
||||
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80) restano solo i
|
||||
pagamenti arrivati da fuori. Il numero di ingressi non è l'unica cosa che può
|
||||
impedire un pagamento: vedi
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento).
|
||||
|
||||
### Cosa succede
|
||||
|
||||
Comportamento atteso:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Richiesta di scommessa"] --> N{"Connessi<br/>alla rete?"}
|
||||
A["Richiesta di scommessa"] --> B["Blocco per-utente acquisito"]
|
||||
B --> N{"Connessi<br/>alla rete?"}
|
||||
N -- "No" --> N1["network_unavailable (503)"]
|
||||
N -- "Sì" --> B["Blocco per-utente acquisito"]
|
||||
B --> C["Controlli (tabella sopra)"]
|
||||
N -- "Sì" --> C["Controlli (tabella sopra)"]
|
||||
C --> D["Scelta delle monete,<br/>dalla più grande"]
|
||||
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi<br/>resto → indirizzo utente"]
|
||||
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi del round<br/>resto → indirizzo utente"]
|
||||
E --> CAS{"Scadenza non passata e<br/>round ancora 'open'<br/>(compare-and-set)?"}
|
||||
CAS -- "No" --> CAS1["Annullato prima dell'invio:<br/>round_closing"]
|
||||
CAS -- "Sì" --> F["Salvataggio PRIMA dell'invio:<br/>monete segnate come spese,<br/>partecipante 'building'"]
|
||||
F --> G{"La rete accetta<br/>l'invio?"}
|
||||
G -- "No" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
|
||||
G -- "Sì" --> I["Partecipante 'broadcast'<br/>(in attesa)"]
|
||||
F --> G{"Risposta del server"}
|
||||
G -- "Rifiuto esplicito" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
|
||||
G -- "Nessuna risposta<br/>(timeout, connessione caduta)" --> U["Nulla liberato: partecipante 'building'<br/>(202), decide il riconciliatore"]
|
||||
G -- "Accettata o già nota" --> I["Partecipante 'broadcast'<br/>(in attesa), inviata anche<br/>agli altri server"]
|
||||
U --> R{"Il riconciliatore<br/>la trova in rete?"}
|
||||
R -- "Sì" --> I
|
||||
R -- "No, e c'è la prova<br/>che non è partita" --> X
|
||||
I --> J{"Confermata entro<br/>il tempo limite?"}
|
||||
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente"] --> J
|
||||
J -- "Mai, e la rete non<br/>la conosce più" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
|
||||
J -- "Sì" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
|
||||
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente;<br/>un resto che finirebbe sotto 294 sat<br/>va tutto in commissione"] --> J
|
||||
J -- "Mai, e c'è la prova<br/>che non è partita" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
|
||||
J -- "Sì (prova merkle del quorum)" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
|
||||
```
|
||||
|
||||
- **[BET-12]** La transazione spende monete **intere** dell'utente, scelte
|
||||
dalla più grande; tutti gli ingressi segnalano la sostituibilità (BIP125,
|
||||
`RBF_SEQUENCE`). Ha due uscite: il costo meno la commissione verso
|
||||
l'indirizzo del montepremi, e il resto (monete spese meno il costo) verso
|
||||
l'indirizzo dell'utente.
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
l'**indirizzo del montepremi del round** (`rounds.pool_address`,
|
||||
[RND-35](ciclo-di-vita-del-round.md#0--apertura)), e il resto (monete
|
||||
spese meno il costo) verso l'indirizzo dell'utente.
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py`
|
||||
*Stato:* da implementare (B-80) · *Oggi:* tutte le scommesse di tutti i
|
||||
round pagano lo stesso indirizzo (`derive_pool_address`,
|
||||
`m/84'/746'/0'/1/0`).
|
||||
- **[BET-13]** La **commissione di rete** è la dimensione stimata della
|
||||
transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da
|
||||
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arrivano
|
||||
10 PLM meno la commissione, e l'importo registrato per il partecipante
|
||||
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arriva
|
||||
il costo meno la commissione, e l'importo registrato per il partecipante
|
||||
(`bet_amount_sats`) è proprio questo netto. Per questo il montepremi si
|
||||
calcola sommando gli importi effettivi delle scommesse, non moltiplicando il
|
||||
numero di giocatori per il costo.
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_rounds_route.py`
|
||||
- **[BET-14]** Il **resto** torna sempre allo stesso indirizzo dell'utente ed è
|
||||
sempre di almeno 294 sat: senza un'uscita di resto non si potrebbe aumentare
|
||||
la commissione in caso di ritardo. A differenza del prelievo, la scommessa
|
||||
non riduce mai il proprio importo per far posto al resto: la rifiuta
|
||||
([BET-09]). Il saldo di un utente che scommette deve quindi essere
|
||||
strettamente maggiore del costo.
|
||||
sempre di almeno 294 sat (`DUST_LIMIT_SATS`). A differenza del prelievo, la
|
||||
scommessa non riduce mai il proprio importo per far posto al resto: la
|
||||
rifiuta ([BET-09]). Le monete scelte devono quindi superare il costo di
|
||||
almeno 294 sat. Questo resto è l'uscita che l'RBF riduce o elimina, secondo
|
||||
la regola di
|
||||
[TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta):
|
||||
un resto di 294 sat non regge una riduzione (l'aumento minimo è di 141 sat a
|
||||
1 sat/vB), quindi garantisce una sola accelerazione, che lo elimina.
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
|
||||
*Test:* `tests/unit/test_withdrawals.py` (i test del resto delle scommesse
|
||||
sono lì: `test_a_bet_from_a_balance_equal_to_the_bet_is_refused`,
|
||||
`test_a_bet_with_a_dust_limit_of_headroom_is_accepted`),
|
||||
`tests/unit/test_psbt_builder.py`
|
||||
- **[BET-15]** **Prima si scrive, poi si invia.** Prima dell'invio, in
|
||||
un'unica transazione del database, le monete spese vengono segnate come tali
|
||||
(con il txid della scommessa), il saldo viene ricalcolato, e si registrano il
|
||||
partecipante (stato `building`, con l'orario di invio che ne determina
|
||||
l'ordine nell'estrazione) e la transazione in corso (stato `building`).
|
||||
partecipante (stato `building`, con l'orario di invio `broadcast_at`) e la
|
||||
transazione in corso (stato `building`). L'ordine dei partecipanti
|
||||
nell'estrazione è definito in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
|
||||
Dettagli in
|
||||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia).
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
- **[BET-16]** Se l'invio fallisce per qualunque motivo, tutto torna com'era:
|
||||
monete liberate, partecipante e transazione in corso **cancellati**, saldo
|
||||
ricalcolato, evento `bet_broadcast_failed` nel registro di audit, notifica
|
||||
SSE; la risposta è `broadcast_failed` (HTTP 502).
|
||||
- **[BET-16]** Se il server **rifiuta esplicitamente** l'invio
|
||||
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)), tutto torna
|
||||
com'era: monete liberate, partecipante e transazione in corso
|
||||
**cancellati**, saldo ricalcolato, evento `bet_broadcast_failed` nel
|
||||
registro di audit, notifica SSE; la risposta è `broadcast_failed`
|
||||
(HTTP 502).
|
||||
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
- **[BET-17]** Se l'invio riesce, il partecipante passa a `broadcast`, la
|
||||
transazione in corso a `pending`, si scrive `bet_placed` (txid e importo
|
||||
netto) e si invia una notifica SSE. La risposta è HTTP 201 con `round_id`,
|
||||
`bet_txid`, `bet_amount_sats` e `status`.
|
||||
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
|
||||
qualunque errore dell'invio, anche un timeout o una connessione caduta
|
||||
dopo la scrittura, quando la transazione può essere già in rete: i suoi
|
||||
sat arrivano al montepremi senza partecipante.
|
||||
- **[BET-23]** Se l'invio ha un **esito incerto**
|
||||
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), partecipante e
|
||||
transazione in corso restano `building` con le monete ancora segnate come
|
||||
spese, e la risposta è HTTP **202** con `round_id`, `bet_txid`,
|
||||
`bet_amount_sats` e `status` = `building`. Il partecipante conta per il
|
||||
limite di [BET-06] e tiene il round in `closing` finché il riconciliatore
|
||||
non lo promuove o lo abbandona.
|
||||
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
|
||||
un rifiuto ([BET-16]).
|
||||
- **[BET-17]** Se l'invio è accettato — anche con un rifiuto "già nota"
|
||||
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il
|
||||
partecipante passa a `broadcast`, la transazione in corso a `pending`, si
|
||||
scrive `bet_placed` (txid e importo netto) e si invia una notifica SSE; la
|
||||
transazione viene poi inviata anche agli altri server
|
||||
([TX-21](transazioni-e-riconciliazione.md#invio-rifiutato)). La risposta è
|
||||
HTTP 201 con `round_id`, `bet_txid`, `bet_amount_sats` e `status`. Una
|
||||
scommessa promossa dal riconciliatore scrive `bet_placed` in quel momento
|
||||
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
|
||||
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
- **[BET-18]** Alla prima conferma il partecipante passa a `confirmed`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
|
||||
rifiuto e la transazione va solo al server attivo.
|
||||
- **[BET-18]** Alla conferma il partecipante passa a `confirmed`
|
||||
([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse
|
||||
**confermate** partecipano all'estrazione; il round, allo scadere, aspetta
|
||||
che tutte quelle già inviate confermino (vedi
|
||||
@@ -167,8 +249,12 @@ esterno scelto dall'utente.
|
||||
|
||||
- **[WDR-01]** Come per la scommessa, un prelievo senza connessione alla rete
|
||||
è rifiutato con `network_unavailable` (HTTP 503), e tutta l'elaborazione
|
||||
avviene tenendo il **blocco per-utente**.
|
||||
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire`
|
||||
avviene tenendo il **blocco per-utente**; il client della rete viene letto
|
||||
e controllato dopo aver acquisito il blocco, come in [BET-02].
|
||||
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` ·
|
||||
*Test:* — (nessun test verifica `network_unavailable`)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
|
||||
controllato prima di acquisire il blocco.
|
||||
|
||||
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
|
||||
HTTP 400 prima che si muova qualunque cifra:
|
||||
@@ -177,7 +263,8 @@ HTTP 400 prima che si muova qualunque cifra:
|
||||
|---|---|---|
|
||||
| **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` |
|
||||
| **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` |
|
||||
| **[WDR-04]** | L'importo è almeno pari al costo corrente di una scommessa (`bet_amount_sats`; non esiste un minimo separato) | `amount_below_minimum` (parametro `minimum_sats`) |
|
||||
| **[WDR-14]** | L'indirizzo di destinazione non è un indirizzo della piattaforma: l'indirizzo del montepremi di un round qualunque (`rounds.pool_address`), l'indirizzo di riserva (`m/84'/746'/0'/1/0`, [RND-35](ciclo-di-vita-del-round.md#0--apertura)), il `fee_address` corrente di `round_config` o quello fotografato su un round. *Stato:* da implementare (B-80) · *Oggi:* nessun controllo; un prelievo verso il montepremi finisce fra le monete di un round | `withdrawal_to_platform_address` |
|
||||
| **[WDR-04]** | L'importo è almeno pari al costo di una scommessa **corrente** in `round_config` (`bet_amount_sats`; non esiste un minimo separato, e non si usa il costo fotografato su un round) | `amount_below_minimum` (parametro `minimum_sats`) |
|
||||
| **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) |
|
||||
| **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) |
|
||||
| **[WDR-07]** | L'importo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
|
||||
@@ -189,7 +276,7 @@ HTTP 400 prima che si muova qualunque cifra:
|
||||
La struttura è identica alla scommessa: transazione firmata dall'indirizzo
|
||||
dell'utente verso l'indirizzo esterno, resto all'utente, **commissione dedotta
|
||||
dall'importo prelevato**, salvataggio prima dell'invio, annullamento completo
|
||||
se la rete rifiuta, RBF se non conferma in tempo.
|
||||
solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
|
||||
|
||||
- **[WDR-08]** La transazione spende monete intere dell'utente (dalla più
|
||||
grande, ingressi sostituibili BIP125); all'indirizzo esterno arriva
|
||||
@@ -198,36 +285,70 @@ se la rete rifiuta, RBF se non conferma in tempo.
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_withdrawals.py`
|
||||
- **[WDR-09]** **Resto garantito.** Se l'importo richiesto lascerebbe un resto
|
||||
inferiore a 294 sat — tipicamente il prelievo dell'intero saldo —, l'importo
|
||||
viene ridotto quanto basta perché il resto sia esattamente 294 sat, che
|
||||
restano sul conto dell'utente. Chi preleva tutto riceve quindi il saldo meno
|
||||
294 sat meno la commissione. Il database registra sia l'importo richiesto
|
||||
(`amount_requested_sats`) sia quello effettivamente inviato al destinatario,
|
||||
al netto della commissione (`amount_sent_sats`).
|
||||
inferiore a 294 sat (`DUST_LIMIT_SATS`) — ad esempio il prelievo
|
||||
dell'intero saldo —, l'importo viene ridotto quanto basta perché il resto
|
||||
sia esattamente 294 sat, che restano sul conto dell'utente. Chi preleva
|
||||
tutto riceve quindi il saldo meno 294 sat meno la commissione. Il database
|
||||
registra sia l'importo richiesto (`amount_requested_sats`) sia quello
|
||||
effettivamente inviato al destinatario, al netto della commissione
|
||||
(`amount_sent_sats`). Come per la scommessa ([BET-14]), questo resto è
|
||||
l'uscita che l'RBF riduce o elimina
|
||||
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
|
||||
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database, le
|
||||
monete spese vengono segnate come tali, il saldo ricalcolato, e si
|
||||
- **[WDR-15]** L'importo ridotto da [WDR-09] viene verificato di nuovo
|
||||
contro il minimo di [WDR-04]: se è sceso sotto `bet_amount_sats`, il
|
||||
prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats`)
|
||||
prima che si muova qualunque cifra.
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo
|
||||
sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare
|
||||
fino a 294 sat meno del minimo.
|
||||
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database
|
||||
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia)),
|
||||
le monete spese vengono segnate come tali, il saldo ricalcolato, e si
|
||||
registrano il prelievo e la transazione in corso, entrambi in stato
|
||||
`building`.
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
- **[WDR-11]** Se l'invio fallisce, le monete vengono liberate e il saldo
|
||||
ricalcolato, ma — a differenza della scommessa — il prelievo **non viene
|
||||
cancellato**: resta come `failed` (senza txid), così come la transazione in
|
||||
corso (con il motivo in `failure_reason`); si scrive
|
||||
`withdrawal_broadcast_failed`, si invia una notifica SSE e la risposta è
|
||||
`broadcast_failed` (HTTP 502).
|
||||
- **[WDR-11]** Se il server **rifiuta esplicitamente** l'invio
|
||||
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)), le monete
|
||||
vengono liberate e il saldo ricalcolato, ma — a differenza della
|
||||
scommessa — il prelievo **non viene cancellato**: resta come `failed`
|
||||
(senza txid), così come la transazione in corso (con il motivo in
|
||||
`failure_reason`); si scrive `withdrawal_broadcast_failed`, si invia una
|
||||
notifica SSE e la risposta è `broadcast_failed` (HTTP 502).
|
||||
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
- **[WDR-12]** Se l'invio riesce, il prelievo passa a `broadcast`, la
|
||||
transazione in corso a `pending`, si scrive `withdrawal_sent` (txid, importo
|
||||
inviato, indirizzo esterno) e si invia una notifica SSE. La risposta è
|
||||
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e `status`.
|
||||
Alla prima conferma il prelievo passa a `confirmed`
|
||||
*Test:* `tests/unit/test_withdrawals.py` (nessun test verifica l'evento
|
||||
`withdrawal_broadcast_failed`)
|
||||
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
|
||||
qualunque errore dell'invio, anche un timeout o una connessione caduta
|
||||
dopo la scrittura: la transazione può essere partita mentre il prelievo
|
||||
risulta `failed` e le monete di nuovo spendibili.
|
||||
- **[WDR-16]** Se l'invio ha un **esito incerto**
|
||||
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), prelievo e
|
||||
transazione in corso restano `building` con le monete ancora segnate come
|
||||
spese, e la risposta è HTTP **202** con `txid`, `amount_requested_sats`,
|
||||
`amount_sent_sats` e `status` = `building`; sarà il riconciliatore a
|
||||
promuoverlo o abbandonarlo.
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
|
||||
un rifiuto ([WDR-11]).
|
||||
- **[WDR-12]** Se l'invio è accettato — anche con un rifiuto "già nota"
|
||||
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il prelievo
|
||||
passa a `broadcast`, la transazione in corso a `pending`, si scrive
|
||||
`withdrawal_sent` (txid, importo inviato, indirizzo esterno) e si invia una
|
||||
notifica SSE; la transazione viene poi inviata anche agli altri server
|
||||
([TX-21](transazioni-e-riconciliazione.md#invio-rifiutato)). La risposta è
|
||||
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e
|
||||
`status`. Alla conferma il prelievo passa a `confirmed`
|
||||
([TX-07](transazioni-e-riconciliazione.md#conferma)).
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
|
||||
rifiuto e la transazione va solo al server attivo.
|
||||
|
||||
## Scommessa e prelievo insieme
|
||||
|
||||
|
||||
@@ -3,33 +3,39 @@
|
||||
Come ogni movimento di denaro — scommessa, prelievo, pagamento del round —
|
||||
viene registrato, inviato, accelerato e infine confrontato con la catena.
|
||||
L'obiettivo è che il sistema si rimetta in ordine da solo dopo un crash o un
|
||||
problema di rete, senza interventi manuali sul database.
|
||||
problema di rete, senza interventi manuali sul database, e che **nel dubbio
|
||||
non liberi mai i fondi**: una transazione si annulla solo con una prova che
|
||||
non è partita, mai per un timeout o per il silenzio di un solo server
|
||||
(decisione 2 di [piano-correzioni.md](piano-correzioni.md#le-decisioni)).
|
||||
|
||||
I requisiti usano il prefisso `TX`, secondo la convenzione di
|
||||
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
|
||||
`pending_transactions` sono descritte in [modello-dati.md](modello-dati.md),
|
||||
gli eventi di audit in [audit-log.md](audit-log.md), le attività in
|
||||
background e le loro cadenze in [architettura.md](architettura.md).
|
||||
[processo-di-modifica.md](processo-di-modifica.md). Le colonne delle tabelle
|
||||
`pending_transactions` e `pending_transaction_txids` sono descritte in
|
||||
[modello-dati.md](modello-dati.md), gli eventi di audit in
|
||||
[audit-log.md](audit-log.md), le attività in background e le loro cadenze in
|
||||
[architettura.md](architettura.md).
|
||||
|
||||
## Il principio: prima si scrive, poi si invia
|
||||
|
||||
Nessuna transazione viene inviata alla rete senza che il database ne abbia già
|
||||
traccia. Ogni transazione in uscita ha una riga `PendingTransaction` che
|
||||
attraversa questi stati:
|
||||
attraversa questi stati (comportamento atteso):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> building: transazione firmata,<br/>monete segnate come spese,<br/>salvato PRIMA dell'invio
|
||||
building --> pending: la rete accetta l'invio
|
||||
building --> [*]: scommessa rifiutata dalla rete<br/>(riga cancellata, tutto annullato)
|
||||
building --> failed: prelievo rifiutato dalla rete<br/>(tutto annullato)
|
||||
building --> pending: il riconciliatore la trova<br/>in rete (dopo 120s)
|
||||
building --> failed: il riconciliatore non la<br/>trova in rete (dopo 120s)
|
||||
building --> pending: il server accetta l'invio<br/>(anche "già nota")
|
||||
building --> building: invio incerto (timeout,<br/>connessione caduta): nulla liberato
|
||||
building --> [*]: scommessa rifiutata esplicitamente<br/>(riga cancellata, tutto annullato)
|
||||
building --> failed: prelievo rifiutato esplicitamente<br/>(tutto annullato)
|
||||
building --> pending: il riconciliatore trova in rete<br/>uno dei suoi txid (dopo 120s)
|
||||
building --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 120s)
|
||||
pending --> pending: nessuna conferma<br/>entro il tempo limite → RBF
|
||||
pending --> confirmed: 1 conferma
|
||||
pending --> failed: il riconciliatore non la<br/>trova in rete (dopo 6h dal primo invio)
|
||||
pending --> confirmed: prova merkle del quorum<br/>per uno dei suoi txid
|
||||
pending --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 6h dal primo invio)
|
||||
failed --> failed: conferma tardiva entro 14 giorni:<br/>pending_tx_resurrected + pausa
|
||||
confirmed --> [*]
|
||||
failed --> [*]
|
||||
failed --> [*]: dopo 14 giorni
|
||||
```
|
||||
|
||||
- **[TX-01]** Ogni transazione in uscita ha una riga `PendingTransaction` di
|
||||
@@ -53,25 +59,104 @@ stateDiagram-v2
|
||||
quello dell'ultimo invio, ed è l'unico che l'RBF aggiorna.
|
||||
*Codice:* `app/db/models.py:PendingTransaction`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-19]** **Tutte le versioni sono registrate.** Ogni txid che una riga
|
||||
ha avuto, con la sua transazione completa, è una riga di
|
||||
`pending_transaction_txids` ([modello-dati.md](modello-dati.md) (B-77)): la
|
||||
prima versione è scritta insieme alla riga `PendingTransaction` ([TX-01]),
|
||||
ogni sostituzione RBF prima di essere inviata ([TX-12]). Conferma
|
||||
([TX-06]), riconciliazione ([TX-15]) e sorveglianza delle righe `failed`
|
||||
([TX-23]) cercano **ogni** txid della riga, non solo `current_txid`. Quando
|
||||
la versione trovata in catena non è quella corrente, `current_txid` e
|
||||
`raw_tx_hex` diventano quelli della versione trovata e il txid viene
|
||||
propagato ovunque sia registrato, come dopo un RBF ([TX-12]).
|
||||
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/reconcile.py:reconcile_once`, `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-77) · *Oggi:* si conserva solo il txid corrente
|
||||
(più il precedente in `replaced_by_txid`, che nessuno legge); se conferma
|
||||
una versione precedente a un RBF non viene riconosciuta.
|
||||
- **[TX-28]** Ogni transazione del database che sposta denaro — la scrittura
|
||||
di `building` ([TX-01]), la promozione dopo l'invio, l'annullamento dopo un
|
||||
rifiuto, le due scritture dell'RBF ([TX-12]), la promozione e l'abbandono
|
||||
del riconciliatore, la conferma ([TX-07]) — si apre con `BEGIN IMMEDIATE`,
|
||||
cioè prende il blocco in scrittura di SQLite **prima** delle proprie
|
||||
letture, così quelle letture non possono cambiare fino al commit. Se il
|
||||
blocco non si ottiene entro il tempo di attesa di SQLite (5 secondi,
|
||||
`_SQLITE_BUSY_TIMEOUT_MS`) l'operazione fallisce con `database is locked`
|
||||
e viene annullata: prima dell'invio non si è mosso nulla; dopo un invio
|
||||
riuscito le righe restano `building` e vale [TX-20]. La durabilità dei
|
||||
commit (`synchronous=FULL`) è descritta in
|
||||
[architettura.md](architettura.md) (B-88).
|
||||
*Codice:* `app/db/base.py:_register_sqlite_pragmas`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-88) · *Oggi:* pysqlite in modalità legacy apre
|
||||
la transazione solo alla prima scrittura, quindi le letture precedenti non
|
||||
sono isolate; un'attesa del blocco oltre 5 secondi emerge come
|
||||
`OperationalError` in un punto qualunque, anche dopo un invio riuscito.
|
||||
|
||||
### Invio rifiutato
|
||||
|
||||
- **[TX-04]** Se l'invio di una scommessa o di un prelievo fallisce, tutto
|
||||
torna com'era: monete liberate, saldo ripristinato, evento scritto nel
|
||||
Un invio può finire in tre modi, e solo uno di essi permette di liberare le
|
||||
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]) |
|
||||
|
||||
- **[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
|
||||
annulla tutto: monete liberate, saldo ripristinato, evento scritto nel
|
||||
registro di audit (`bet_broadcast_failed`, `withdrawal_broadcast_failed`),
|
||||
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. Qualunque errore
|
||||
dell'invio è trattato così, anche la scadenza del tempo di risposta.
|
||||
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||||
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` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
|
||||
- **[TX-05]** Se l'invio di un pagamento fallisce, la riga resta `building`
|
||||
e si scrive `payout_failed` (`broadcast rejected`): è il riconciliatore a
|
||||
decidere, interrogando la rete, se la transazione è partita o no
|
||||
([TX-16], [TX-17]).
|
||||
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione dell'invio,
|
||||
compresi il timeout di 15 secondi, la connessione caduta dopo la scrittura
|
||||
e il "già nota", annulla tutto e risponde 502, anche se la transazione può
|
||||
essere già in rete.
|
||||
- **[TX-20]** **Invio incerto.** Se l'invio di una scommessa o di un prelievo
|
||||
finisce senza una risposta del server (timeout, connessione caduta, errore
|
||||
di scrittura), o se il commit della promozione successiva a un invio
|
||||
accettato fallisce ([TX-28]), non si libera nulla: monete spese,
|
||||
partecipante o prelievo e riga `PendingTransaction` restano `building`, e
|
||||
sarà il riconciliatore a stabilire se la transazione è partita ([TX-16],
|
||||
[TX-17]). L'utente riceve HTTP **202** con l'oggetto nello stato
|
||||
`building` ([BET-23](scommesse-e-prelievi.md#cosa-succede),
|
||||
[WDR-16](scommesse-e-prelievi.md#prelievo)) e si invia una notifica SSE.
|
||||
*Codice:* `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-76) · *Oggi:* l'invio incerto è trattato come
|
||||
un rifiuto ([TX-04]); un commit fallito dopo un invio accettato solleva un
|
||||
errore 500 lasciando le righe `building`.
|
||||
- **[TX-21]** **Invio a tutti i server.** Dopo che il server attivo ha
|
||||
accettato una transazione ([TX-04]) — scommessa, prelievo, pagamento o
|
||||
sostituzione RBF — la stessa transazione viene inviata anche a ogni altro
|
||||
server configurato. È un tentativo in più, non una condizione: un errore di
|
||||
questi invii viene solo registrato nei log, non cambia lo stato della riga
|
||||
e non ritarda la risposta all'utente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-76) · *Oggi:* ogni transazione viene inviata
|
||||
solo al server attivo.
|
||||
- **[TX-05]** Se l'invio di un pagamento non è accettato — rifiuto esplicito
|
||||
o esito incerto — la riga resta `building` e si scrive `payout_failed`
|
||||
(`broadcast rejected`): è il riconciliatore a decidere, interrogando la
|
||||
rete, se la transazione è partita o no ([TX-16], [TX-17]). Un rifiuto
|
||||
"già nota" ([TX-04]) vale come invio accettato: la riga passa a `pending` e
|
||||
si scrive `payout_sent`.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* anche il "già nota" lascia la
|
||||
riga `building` con `payout_failed`, finché il riconciliatore non la
|
||||
promuove.
|
||||
|
||||
## Conferma
|
||||
|
||||
@@ -79,13 +164,33 @@ stateDiagram-v2
|
||||
in stato `pending` (non quelle `building`), e salta il giro se non c'è
|
||||
connessione. Per ciascuna legge la cronologia
|
||||
(`blockchain.scripthash.get_history`) dell'indirizzo che la spende —
|
||||
l'indirizzo dell'utente, o quello del montepremi per un pagamento — e la
|
||||
considera confermata quando il suo txid corrente vi compare con altezza
|
||||
maggiore di 0. La cronologia è letta una volta per indirizzo a ogni giro;
|
||||
un indirizzo che non si riesce a leggere non blocca gli altri.
|
||||
l'indirizzo dell'utente, o l'indirizzo del montepremi del round per un
|
||||
pagamento — e cerca **ogni** txid della riga ([TX-19]); un txid che vi
|
||||
compare con altezza maggiore di 0 è il candidato alla conferma, che viene
|
||||
accettata solo alle condizioni di [TX-22]. La cronologia è letta una volta
|
||||
per indirizzo a ogni giro; un indirizzo che non si riesce a leggere non
|
||||
blocca gli altri.
|
||||
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` ·
|
||||
*Test:* `tests/unit/test_confirmation.py`
|
||||
- **[TX-07]** Alla prima conferma la riga passa a `confirmed` e, nella stessa
|
||||
*Stato:* da implementare (B-77, B-80) · *Oggi:* cerca solo `current_txid`,
|
||||
nella cronologia dell'unico indirizzo del montepremi condiviso da tutti i
|
||||
round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server
|
||||
attivo basta a confermare ([TX-22]).
|
||||
- **[TX-22]** **Conferma verificata.** Un candidato ([TX-06]) è confermato
|
||||
solo quando il quorum dei server
|
||||
([connessione-electrum.md](connessione-electrum.md) (B-84)) restituisce per
|
||||
quel txid una prova merkle valida (`blockchain.transaction.get_merkle`)
|
||||
contro l'header dell'altezza indicata. L'header è quello già validato dal
|
||||
listener ([connessione-electrum.md](connessione-electrum.md) (B-74)); se
|
||||
il listener non lo tiene più, è quello su cui il quorum concorda. Senza
|
||||
quorum, o con una prova non valida, la riga resta `pending` e si riprova al
|
||||
giro successivo.
|
||||
*Codice:* `app/tx/confirmation.py:poll_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-83) · *Oggi:* basta la parola del server
|
||||
attivo: un server ostile può dichiarare confermato un pagamento mai
|
||||
trasmesso.
|
||||
- **[TX-07]** Alla conferma la riga passa a `confirmed` e, nella stessa
|
||||
transazione del database, viene chiamato il gestore del suo tipo; poi si
|
||||
invia una notifica SSE:
|
||||
|
||||
@@ -119,7 +224,7 @@ sostituibilità fin dal primo invio.
|
||||
`pending` (salta il giro senza connessione) e accelera quelle il cui
|
||||
`last_broadcast_at` è più vecchio di `rbf_timeout_seconds`: dopo ogni
|
||||
accelerazione la transazione ha di nuovo l'intero tempo limite. Un errore
|
||||
su una riga viene registrato nei log e non ferma le altre.
|
||||
su una riga non ferma le altre.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:should_bump` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-10]** La nuova commissione punta a 1 sat/vB in più della tariffa
|
||||
@@ -131,35 +236,86 @@ sostituibilità fin dal primo invio.
|
||||
precedenti come interi in satoshi.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-11]** **Paga chi riceve il resto**: la sostituzione spende le stesse
|
||||
monete e riduce del solo aumento l'uscita di resto verso il mittente —
|
||||
l'utente per scommesse e prelievi, il montepremi per i pagamenti. Le uscite
|
||||
verso la controparte (montepremi, indirizzo esterno, vincitore, indirizzo
|
||||
commissioni) non vengono mai toccate. Se non c'è un'uscita di resto verso il
|
||||
mittente, o non supera l'aumento, l'accelerazione non è possibile: l'errore
|
||||
viene registrato nei log, la transazione resta in attesa (il tentativo si
|
||||
ripete a ogni giro) e prima o poi viene abbandonata dal riconciliatore, che
|
||||
ne libera le monete.
|
||||
- **[TX-11]** **Chi paga l'accelerazione.** La sostituzione spende le stesse
|
||||
monete e riduce del solo aumento un'unica uscita, quella che torna alla
|
||||
piattaforma o al mittente: il **resto dell'utente** per scommesse e
|
||||
prelievi, l'**uscita di riserva** verso l'indirizzo di riserva del
|
||||
montepremi (`m/84'/746'/0'/1/0`) per i pagamenti
|
||||
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)
|
||||
(B-79)). Le uscite verso la controparte (montepremi, indirizzo esterno,
|
||||
vincitore, indirizzo commissioni) non vengono mai toccate. Se dopo la
|
||||
riduzione quell'uscita scenderebbe sotto `DUST_LIMIT_SATS` (294 sat),
|
||||
l'uscita viene **eliminata** e tutto il suo valore va in commissione: la
|
||||
sostituzione non ha più nulla da ridurre ed è quindi **l'ultima**, e la
|
||||
riga non viene più accelerata. Se l'uscita non esiste, o se nemmeno la sua
|
||||
eliminazione raggiunge l'aumento minimo di [TX-10], l'accelerazione non è
|
||||
possibile ([TX-24]). Una transazione che non si può accelerare resta
|
||||
`pending` finché conferma o finché il riconciliatore ha la prova che non è
|
||||
partita ([TX-17]): finché almeno un server la tiene in mempool non viene
|
||||
mai abbandonata.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-12]** Dopo l'invio della sostituzione la riga registra il nuovo txid
|
||||
(`current_txid`), la nuova transazione (`raw_tx_hex`), il txid sostituito
|
||||
(`replaced_by_txid`), la tariffa effettivamente pagata, il numero di
|
||||
tentativi e `last_broadcast_at`. Il nuovo txid viene propagato ovunque sia
|
||||
registrato: monete spese (`spent_txid`), partecipante (`bet_txid`),
|
||||
prelievo (`txid`), round (`payout_txid`).
|
||||
*Stato:* da implementare (B-79, B-87) · *Oggi:* per il pagamento si cerca
|
||||
un resto verso l'indirizzo del montepremi, che esiste solo se le monete
|
||||
scelte superano vincitore più commissioni di almeno 294 sat: quando il
|
||||
montepremi contiene esattamente le scommesse del round il pagamento le
|
||||
spende tutte per esattamente `pool_amount_sats`, non ha resto e non è
|
||||
accelerabile; per scommesse e prelievi basta che il resto superi
|
||||
l'aumento, così un resto di 294 sat diventa 153 sat, sotto la soglia di
|
||||
dust, e la rete rifiuta la sostituzione a ogni giro.
|
||||
- **[TX-12]** **Prima si scrive la sostituzione, poi si invia.** La
|
||||
sostituzione firmata viene registrata come candidata in
|
||||
`pending_transaction_txids` (txid e transazione completa, [TX-19]) e
|
||||
committata **prima** dell'invio. Dopo l'invio (accettato o incerto) la riga
|
||||
viene aggiornata con un aggiornamento condizionato
|
||||
(`UPDATE … WHERE id = … AND status = 'pending' AND current_txid = <txid
|
||||
sostituito>`): nuovo txid (`current_txid`), nuova transazione
|
||||
(`raw_tx_hex`), txid sostituito (`replaced_by_txid`), tariffa
|
||||
effettivamente pagata, numero di tentativi e `last_broadcast_at`; nella
|
||||
stessa transazione il nuovo txid viene propagato ovunque sia registrato:
|
||||
monete spese (`spent_txid`), partecipante (`bet_txid`), prelievo (`txid`),
|
||||
round (`payout_txid`). Se la sostituzione è rifiutata esplicitamente la
|
||||
riga non cambia ([TX-24]).
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_retarget_txid_references` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-13]** Se al momento dell'accelerazione la riga non esiste più o non è
|
||||
più `pending` (ad esempio è appena stata confermata), non si fa nulla.
|
||||
*Stato:* da implementare (B-77) · *Oggi:* la sostituzione viene inviata
|
||||
senza essere registrata, e la riga viene aggiornata solo dopo l'invio, senza
|
||||
condizioni: un crash o un `database is locked` in mezzo lascia in rete una
|
||||
transazione che il database non conosce.
|
||||
- **[TX-13]** L'RBF non tocca una riga che è cambiata sotto di lui. Prima di
|
||||
costruire la sostituzione, se la riga non esiste più o non è più
|
||||
`pending` (ad esempio è appena stata confermata), non si fa nulla. Dopo
|
||||
l'invio, se l'aggiornamento condizionato di [TX-12] non trova la riga
|
||||
(stato o `current_txid` cambiati nel frattempo: confermata, abbandonata o
|
||||
già sostituita), è un **conflitto**: la riga non viene sovrascritta, la
|
||||
candidata resta registrata — la conferma e la riconciliazione la cercano
|
||||
comunque ([TX-19]) — e il conflitto viene scritto nei log.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-77) · *Oggi:* il controllo avviene solo prima
|
||||
della costruzione; dopo l'invio la riga viene aggiornata senza ricontrollare
|
||||
né lo stato né `current_txid`, anche se nel frattempo è stata confermata o
|
||||
abbandonata.
|
||||
- **[TX-24]** Un'accelerazione impossibile ([TX-11], o la riga già a
|
||||
`MAX_FEE_RATE_SAT_VB`) o una sostituzione rifiutata esplicitamente dalla
|
||||
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]).
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore finisce solo nei log
|
||||
dell'applicazione, invisibile da `/admin`.
|
||||
|
||||
Scommesse e prelievi nascono sempre con un'uscita di resto di almeno 294 sat
|
||||
proprio perché l'RBF abbia qualcosa da ridurre
|
||||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md)). Il pagamento del round
|
||||
invece ha un resto verso il montepremi solo quando le monete spese superano il
|
||||
montepremi di almeno 294 sat: senza resto non è accelerabile.
|
||||
Il resto minimo di scommesse e prelievi è di 294 sat
|
||||
([BET-14](scommesse-e-prelievi.md#cosa-succede),
|
||||
[WDR-09](scommesse-e-prelievi.md#prelievo)), ma non basta a una riduzione:
|
||||
una scommessa con una moneta in ingresso e due uscite misura 141 vB, quindi
|
||||
l'aumento minimo a 1 sat/vB è di 141 sat e lascerebbe un resto di 153 sat,
|
||||
sotto la soglia di dust. Con un resto minimo la prima accelerazione elimina il
|
||||
resto ([TX-11]) ed è anche l'ultima; un resto più grande permette più
|
||||
accelerazioni prima di arrivare lì. Il pagamento del round, invece, non ha
|
||||
resto quando il montepremi contiene esattamente le scommesse del round: la
|
||||
riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
|
||||
## Riconciliazione
|
||||
|
||||
@@ -168,34 +324,102 @@ montepremi di almeno 294 sat: senza resto non è accelerabile.
|
||||
ancora `building` o `pending` trascorso il suo periodo di tolleranza,
|
||||
misurato da `broadcast_at` (il primo invio, che l'RBF non sposta): 120s per
|
||||
`building`, 6 ore per `pending`, così l'RBF ha il tempo di fare i suoi
|
||||
tentativi.
|
||||
tentativi. Il periodo di tolleranza stabilisce solo da quando la riga viene
|
||||
esaminata: l'abbandono richiede comunque una prova ([TX-17]).
|
||||
*Codice:* `app/tx/reconcile.py:PendingTransactionReconciler.run`, `app/tx/reconcile.py:_is_due` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
- **[TX-15]** L'esistenza di una transazione si decide cercando il suo txid
|
||||
corrente nella cronologia (`blockchain.scripthash.get_history`)
|
||||
dell'indirizzo che la spende, in mempool o in un blocco. Un **errore di
|
||||
rete** non porta a nulla: la riga resta com'è fino al giro successivo. Solo
|
||||
un server che risponde con una cronologia priva del txid porta
|
||||
all'abbandono, mai un server irraggiungibile.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
|
||||
- **[TX-15]** Il riconciliatore chiede a ogni server del quorum
|
||||
([connessione-electrum.md](connessione-electrum.md) (B-84)) la cronologia
|
||||
(`blockchain.scripthash.get_history`) dell'indirizzo che spende la riga, e
|
||||
cerca **ogni** txid della riga ([TX-19]), in mempool o in un blocco. La
|
||||
transazione è **presente** se almeno un server conosce almeno uno dei suoi
|
||||
txid ([TX-16]); è **assente** solo se nessun server del quorum ne conosce
|
||||
alcuno, e anche in quel caso l'assenza da sola non basta ad abbandonarla
|
||||
([TX-17]). Un **errore di rete** non porta a nulla: la riga resta com'è
|
||||
fino al giro successivo.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-78) · *Oggi:* si interroga solo il server
|
||||
attivo e solo per `current_txid`, e un txid assente dalla sua cronologia è
|
||||
trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso
|
||||
una riga `building` viene abbandonata dopo 120s anche se la transazione è
|
||||
nella mempool degli altri nodi (che la tengono fino a 336 ore con le
|
||||
impostazioni predefinite di Bitcoin Core, contro le 6 ore di tolleranza).
|
||||
- **[TX-26]** Prima di scrivere un esito, il riconciliatore rilegge la riga
|
||||
nella propria transazione del database ([TX-28]) e non fa nulla se il suo
|
||||
stato **o il suo `current_txid`** non sono più quelli che aveva quando ha
|
||||
interrogato la rete (una conferma o un RBF avvenuti nel frattempo).
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-78) · *Oggi:* ricontrolla solo lo stato, quindi
|
||||
può abbandonare una riga appena sostituita dall'RBF sulla base del txid
|
||||
precedente.
|
||||
- **[TX-16]** **Presente in rete**: una riga `building` viene promossa a
|
||||
`pending` (e partecipante o prelievo da `building` a `broadcast`), con un
|
||||
evento `pending_tx_recovered`; da lì la conferma segue la via normale. Una
|
||||
riga già `pending` presente in rete non viene toccata.
|
||||
evento `pending_tx_recovered` e l'evento che l'invio riuscito avrebbe
|
||||
scritto: `bet_placed` per una scommessa, `withdrawal_sent` per un
|
||||
prelievo, `payout_sent` per un pagamento. Se il txid trovato non è quello
|
||||
corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già
|
||||
`pending` presente in rete non viene toccata.
|
||||
*Codice:* `app/tx/reconcile.py:_promote` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
- **[TX-17]** **Sconosciuta alla rete**: la riga passa a `failed` con motivo
|
||||
`not found on chain`; le monete che risultano ancora impegnate da *questa*
|
||||
transazione vengono liberate (non quelle già spese da altro); il
|
||||
partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il
|
||||
saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned`
|
||||
(con il numero di monete liberate) e si invia una notifica SSE.
|
||||
*Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-96) · *Oggi:* si scrive solo
|
||||
`pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno
|
||||
mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha
|
||||
`withdrawal_sent`).
|
||||
- **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo
|
||||
se vale una di queste due prove:
|
||||
1. un suo ingresso risulta **speso in un blocco** da una transazione che
|
||||
non è nessuna delle sue versioni ([TX-19]), confermato dal quorum
|
||||
([connessione-electrum.md](connessione-electrum.md) (B-84));
|
||||
2. nessun server del quorum conosce alcuno dei suoi txid ([TX-15]) **e**
|
||||
il reinvio della sua versione più recente (`raw_tx_hex`) a tutti i server
|
||||
viene rifiutato esplicitamente perché gli ingressi mancano o sono già
|
||||
spesi (`missing-inputs`, `bad-txns-inputs-missingorspent`).
|
||||
|
||||
È questo che rende finita l'attesa del round in `closing`: una scommessa che
|
||||
non confermerà mai viene prima o poi abbandonata e rimossa.
|
||||
Un rifiuto del reinvio per commissione insufficiente non è una prova: una
|
||||
riga `pending` resta com'è e la accelera l'RBF ([TX-09]); una riga
|
||||
`building` passa a `pending` come in [TX-16], così che l'RBF la possa
|
||||
accelerare. Un reinvio accettato da almeno un server significa che la
|
||||
transazione è viva: vale [TX-16]. Con la prova, la riga passa a `failed`
|
||||
con il motivo in `failure_reason`; le monete che risultano ancora impegnate
|
||||
da *questa* transazione vengono liberate (non quelle già spese da altro);
|
||||
il partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il
|
||||
saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned` (con
|
||||
il numero di monete liberate) e si invia una notifica SSE.
|
||||
*Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs`, `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del
|
||||
server attivo non contenga `current_txid` (motivo `not found on chain`),
|
||||
senza reinvio e senza guardare gli ingressi.
|
||||
- **[TX-23]** **Le righe `failed` restano sorvegliate** per 14 giorni dal
|
||||
passaggio a `failed` (`failed_at`, [modello-dati.md](modello-dati.md)
|
||||
(B-78)): a ogni giro il riconciliatore cerca i loro txid come in [TX-15].
|
||||
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é
|
||||
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` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più
|
||||
guardata: se la transazione conferma dopo l'abbandono, nessuno se ne
|
||||
accorge.
|
||||
- **[TX-27]** Il riconciliatore invia una notifica SSE solo per le righe di
|
||||
cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid),
|
||||
non per ogni riga esaminata.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni
|
||||
riga che ha interrogato, anche quando non cambia nulla.
|
||||
- **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing`
|
||||
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in
|
||||
due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non
|
||||
esiste un tempo massimo: finché almeno un server tiene la transazione in
|
||||
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`
|
||||
|
||||
## Pagamenti del round: riprovare senza pagare due volte
|
||||
|
||||
@@ -211,12 +435,17 @@ monete del montepremi già impegnate da un pagamento non risolto sono escluse
|
||||
dalla selezione ([RND-26](ciclo-di-vita-del-round.md#4--pagamento)). Senza
|
||||
queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
|
||||
|
||||
- **[TX-18]** Un pagamento abbandonato non libera monete utente (quelle del
|
||||
montepremi non sono registrate): azzera `payout_txid` del round, che resta
|
||||
in `paying_out` senza transazione, e lo scheduler ricostruisce il pagamento
|
||||
al tentativo successivo.
|
||||
- **[TX-18]** Un pagamento abbandonato ([TX-17]) non libera monete utente
|
||||
(quelle del montepremi non sono registrate): azzera `payout_txid`,
|
||||
`winner_amount_sats` e `fee_amount_sats` del round, che resta in
|
||||
`paying_out` senza transazione né importi di un pagamento che non esiste, e
|
||||
lo scheduler ricostruisce il pagamento al tentativo successivo.
|
||||
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un
|
||||
round in `paying_out`, non la sequenza abbandono → ricostruzione)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`;
|
||||
`winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento
|
||||
abbandonato.
|
||||
|
||||
## Dove guardare
|
||||
|
||||
@@ -226,5 +455,7 @@ Nel pannello `/admin` (vedi [guida-admin.md](guida-admin.md) e
|
||||
- **Transazioni pendenti** — tutto ciò che è `building` o `pending`, con
|
||||
orari di invio e candidati all'RBF;
|
||||
- **Audit log** — `payout_sent`, `payout_failed`, `pending_tx_recovered`,
|
||||
`pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi;
|
||||
`pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi; con le
|
||||
correzioni in corso anche `rbf_bump_failed` ([TX-24]) e
|
||||
`pending_tx_resurrected` ([TX-23]);
|
||||
- **Round** — stato di ogni round, vincitore e txid del pagamento.
|
||||
Reference in new issue
Block a user