poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
420 lines
27 KiB
Markdown
420 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`, `app/wallet/hd.py:derive_round_pool_key` ·
|
||
*Test:* `tests/unit/test_round_pool_address.py`, `tests/unit/test_pending_address.py`
|
||
|
||
## 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`, `tests/unit/test_confirmation_proof.py`
|
||
- **[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`, `tests/unit/test_confirmation_proof.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`)
|
||
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`.
|