The two Mermaid diagrams under flowchart/ described the intended flow but had drifted from the code and carried no link back to it. docs/ now holds the specification instead: 15 Italian documents (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history) with 472 numbered requirements, each pointing at the function that implements it and the test that covers it. docs/processo-di-modifica.md defines the ID convention and the spec -> code -> test change workflow, and CLAUDE.md now names docs/ as authoritative. Writing the spec from the code also corrected stale claims in CLAUDE.md (B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control scope, the error contract's bare-string exceptions) and in the operator guides (payouts do retry, only `drawing` fails to resume, rounds do not open without fee_address). The B-70 doc test now pins the prose in docs/scommesse-e-prelievi.md instead of the deleted diagram. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
17 KiB
Markdown
288 lines
17 KiB
Markdown
# Registrazione, depositi e saldo
|
|
|
|
Fasi 1 e 2 del [funzionamento generale](come-funziona.md): come nasce un
|
|
account e come i fondi entrano nella piattaforma.
|
|
|
|
I requisiti usano i prefissi `REG` (registrazione e account) e `DEP` (depositi
|
|
e saldo), secondo la convenzione di
|
|
[processo-di-modifica.md](processo-di-modifica.md). Gli endpoint citati sono
|
|
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).
|
|
|
|
## Registrazione
|
|
|
|
L'utente sceglie username e password (`POST /auth/register`). Il server:
|
|
|
|
1. salva la password come hash **Argon2** (mai in chiaro);
|
|
2. assegna all'utente il prossimo indice libero e deriva da questo il suo
|
|
indirizzo, secondo lo schema BIP84 `m/84'/746'/0'/0/<indice>` a partire
|
|
dalla master xprv cifrata. Ne risulta un indirizzo P2WPKH (`plm1…`);
|
|
3. collega l'indirizzo al profilo, in modo **permanente**;
|
|
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).
|
|
|
|
- **[REG-01]** Lo username è lungo da 3 a 32 caratteri, composto solo da
|
|
lettere ASCII, cifre, `_`, `.` e `-`; la password è lunga da 8
|
|
(`MIN_PASSWORD_LENGTH`) a 256 caratteri. Una richiesta che non rispetta
|
|
questi vincoli è rifiutata con HTTP 422 prima di qualunque altra verifica.
|
|
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/security.py:MIN_PASSWORD_LENGTH` ·
|
|
*Test:* `tests/unit/test_users.py`
|
|
- **[REG-02]** Gli username **non distinguono maiuscole e minuscole**:
|
|
`Mario` e `mario` sono lo stesso account. Uno username già usato, in
|
|
qualunque combinazione di maiuscole, è rifiutato con `username_taken`
|
|
(HTTP 409), sia dal controllo iniziale sia se la collisione emerge solo al
|
|
salvataggio (registrazione concorrente). L'unicità è garantita dal database
|
|
con un indice univoco su `lower(username)`. Il nome resta però salvato come
|
|
è stato digitato.
|
|
*Codice:* `app/auth/routes.py:register`, `app/db/models.py:User` ·
|
|
*Test:* `tests/unit/test_auth.py`
|
|
- **[REG-03]** La password è salvata solo come hash Argon2, calcolato fuori
|
|
dal ciclo di eventi (in un thread separato) per non bloccare le attività in
|
|
background.
|
|
*Codice:* `app/auth/security.py:hash_password_async` ·
|
|
*Test:* `tests/unit/test_security.py`
|
|
- **[REG-04]** L'indice di derivazione del nuovo utente è il massimo indice
|
|
esistente più uno (0 per il primo utente); l'indirizzo è il P2WPKH di
|
|
`m/84'/746'/0'/0/<indice>`, con prefisso bech32 `plm`. Se il salvataggio
|
|
fallisce per una collisione sull'indice (due registrazioni concorrenti), si
|
|
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`
|
|
- **[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
|
|
"indirizzo vincite" separato.
|
|
*Codice:* `app/db/models.py:User`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
|
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
|
|
- **[REG-06]** Una registrazione riuscita risponde HTTP 201 con un token di
|
|
sessione (`access_token`) e l'indirizzo: l'utente è già autenticato, senza
|
|
un login separato.
|
|
*Codice:* `app/auth/routes.py:register` ·
|
|
*Test:* `tests/unit/test_users.py`
|
|
- **[REG-07]** Subito dopo il salvataggio, l'indirizzo viene aggiunto agli
|
|
indirizzi osservati e, se la connessione Electrum è attiva, gli ci si iscrive
|
|
in un'attività separata. Se non c'è connessione, o l'iscrizione fallisce,
|
|
l'errore viene solo registrato nei log: la registrazione riesce comunque, e
|
|
l'indirizzo viene ripreso dalla reiscrizione completa alla prossima
|
|
riconnessione ([ELC-18](connessione-electrum.md#riconnessione)) o dal giro
|
|
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`
|
|
|
|
### 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.
|
|
|
|
- **[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` ·
|
|
*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`
|
|
- **[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
|
|
restituisce un token nuovo perché la sessione corrente continui senza nuovo
|
|
login.
|
|
*Codice:* `app/api/routes/users.py:change_password` ·
|
|
*Test:* `tests/unit/test_users.py`
|
|
- **[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
|
|
nuova, la restituisce **una sola volta** perché venga comunicata
|
|
all'utente, incrementa `token_version` (invalidando tutte le sessioni) e
|
|
scrive l'evento `admin_password_reset`. Un utente inesistente dà HTTP 404.
|
|
Procedura in [guida-admin.md](guida-admin.md).
|
|
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
|
|
*Test:* `tests/unit/test_admin.py`
|
|
|
|
### 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.
|
|
|
|
- **[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)).
|
|
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` ·
|
|
*Test:* `tests/unit/test_pending_address.py`
|
|
|
|
## Deposito
|
|
|
|
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno. Non c'è
|
|
un importo minimo.
|
|
|
|
```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"]
|
|
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)"]
|
|
B --> H{"Una moneta data per spesa<br/>fuori è ricomparsa?"}
|
|
H -- "Sì" --> 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.
|
|
|
|
- **[DEP-01]** Non c'è un importo minimo di deposito.
|
|
*Codice:* `app/deposits/service.py:credit_confirmed_utxos`
|
|
- **[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.
|
|
*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
|
|
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` ·
|
|
*Test:* `tests/unit/test_deposits.py`
|
|
- **[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
|
|
([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
|
|
successivo (notifica o giro periodico), e nei log resta un avviso.
|
|
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
|
|
*Test:* `tests/unit/test_electrum_listener.py`
|
|
- **[DEP-05]** L'accredito registra la moneta (txid, posizione, importo,
|
|
altezza) in modo idempotente — una moneta già registrata non viene mai
|
|
accreditata due volte, e il database impone l'unicità della coppia
|
|
txid/posizione —, scrive per ciascuna un evento `deposit_credited`,
|
|
ricalcola il saldo e invia una notifica SSE.
|
|
*Codice:* `app/deposits/service.py:credit_confirmed_utxos` ·
|
|
*Test:* `tests/unit/test_deposits.py`
|
|
- **[DEP-06]** **Monete spese altrove.** Una moneta registrata come non spesa
|
|
che non compare più nell'elenco è *candidata* alla spesa esterna (ad esempio
|
|
perché qualcuno ha usato la chiave privata esportata dall'admin). Le monete
|
|
spese dalla piattaforma stessa non sono mai candidate, perché vengono
|
|
segnate come spese già al momento della costruzione della transazione. Se
|
|
l'elenco è **completamente vuoto** mentre il database conosce monete non
|
|
spese, non si produce alcun candidato: una risposta vuota è trattata come
|
|
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
|
|
([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,
|
|
si scrive `utxo_spent_externally`, si ricalcola il saldo e si invia una
|
|
notifica SSE.
|
|
*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 è
|
|
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` ·
|
|
*Test:* `tests/unit/test_deposits.py`
|
|
- **[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
|
|
stessa funzione di aggiornamento, così i due percorsi non possono
|
|
comportarsi diversamente.
|
|
*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.
|
|
*Codice:* `app/deposits/reconcile.py:DepositReconciler.run`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
|
*Test:* `tests/unit/test_deposit_reconcile.py`
|
|
|
|
## Il saldo mostrato
|
|
|
|
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.
|
|
|
|
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 |
|
|
|
|
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`).
|
|
|
|
- **[DEP-11]** Il saldo confermato (`balance_sats`, colonna
|
|
`cached_balance_sats`) è la somma delle monete registrate dell'utente non
|
|
segnate come spese. Viene ricalcolato nella stessa transazione del database
|
|
di ogni operazione che registra, spende, libera o reintegra monete.
|
|
*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
|
|
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.
|
|
*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
|
|
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` ·
|
|
*Test:* `tests/unit/test_withdrawals.py`
|