A reply line over asyncio's default 64 KiB StreamReader limit raised out of _read_loop and closed the only connection; the pool address's get_history passes that after ~800 transactions, and dust sent to any public address does it on purpose. - ElectrumClient opens the stream with a 16 MiB limit and frames lines itself: an oversized line is discarded up to its newline, a non-JSON line is dropped, and the request whose id is readable in the first/last 256 bytes fails at once; the connection stays up either way. - Coins below DUST_LIMIT_SATS are never offered as credit candidates, never credited, and select_utxos never selects them. - Spec (ELC-20, DEP-01, DAT-10, ARC), storico, BUGS.md and test counts updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
428 lines
27 KiB
Markdown
428 lines
27 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). 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
|
||
|
||
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`
|
||
([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
|
||
(`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` (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
|
||
"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]~~ 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 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. 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`
|
||
*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). 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. 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
|
||
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 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]** 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.
|
||
|
||
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
|
||
([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)).
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
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 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ì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
|
||
```
|
||
|
||
*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 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`, `app/wallet/psbt_builder.py:select_utxos` ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_psbt_builder.py`
|
||
- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco
|
||
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`
|
||
*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), 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 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
|
||
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 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,
|
||
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 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. 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
|
||
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`)
|
||
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
|
||
|
||
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. 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 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, 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
|
||
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ù: (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); (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`
|
||
*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 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`.
|