Files
plm-lottery/docs/connessione-electrum.md
T

186 lines
10 KiB
Markdown
Raw Normal View History

# Connessione alla rete PLM (Electrum)
Il server non gestisce un nodo completo: parla con la rete PLM esclusivamente
tramite il **protocollo Electrum**, con **una sola connessione** alla volta.
Da quella connessione passa tutto — accredito dei depositi, invio delle
transazioni, conferme, altezza della catena su cui attende l'estrazione — per
cui è sia il principale punto di rottura sia, se il server dall'altra parte
fosse ostile, il principale rischio per l'integrità.
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).
Le difese sono cinque.
## 1. Rotazione tra server
`ELECTRUM_HOST`/`ELECTRUM_PORT` è il server principale;
`ELECTRUM_FALLBACK_SERVERS` è un elenco di riserva nel formato
`host:porta[:notls]`, 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.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[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
volta di seguito. Una sessione che si era stabilita azzera l'attesa. Senza
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`
## 2. Richieste con limite di tempo
- **[ELC-04]** Ogni richiesta ha un limite di 15 secondi
(`_REQUEST_TIMEOUT_SECONDS`); allo scadere la connessione viene chiusa, la
sessione termina e si passa al server successivo. Quando la connessione cade,
tutte le richieste in attesa falliscono subito invece di restare sospese.
Senza questo limite una risposta mai arrivata poteva bloccare una scommessa
(con il blocco per-utente acquisito) o fermare per sempre il controllo delle
conferme.
*Codice:* `app/electrum/client.py:ElectrumClient.request`, `app/electrum/client.py:ElectrumClient._read_loop` ·
*Test:* `tests/unit/test_electrum_client.py`
## 3. Caduta rilevata
- **[ELC-05]** La connessione viene pingata (`server.ping`) ogni 60 secondi
(`_PING_INTERVAL_SECONDS`); un ping fallito chiude la sessione.
*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` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[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
del contenuto binario) e non si conosce ancora alcuna altezza, la sessione
viene rifiutata e si passa al server successivo. Chi trova il client assente
(ad esempio durante una riconnessione) salta semplicemente quel giro, e
scommesse e prelievi rispondono `network_unavailable`.
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once` ·
*Test:* `tests/unit/test_electrum_listener.py`
## 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.
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;
- **[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.
- **[ELC-11]** L'header deve essere lungo 80 byte e il suo hash deve
rispettare la difficoltà che l'header stesso dichiara (campo `bits`).
*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` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
- **[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.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._consume_headers` ·
*Test:* `tests/unit/test_electrum_listener.py`
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]).
## 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).
- **[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.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
| 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-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) |
*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
*Test:* `tests/unit/test_electrum_listener.py`
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
stesso quorum. Le conseguenze di una verifica non riuscita:
- un accredito non verificato è solo **rimandato**, non perso: la moneta viene
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)).
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.
## Riconnessione
- **[ELC-18]** Alla riconnessione il listener si reiscrive agli indirizzi di
tutti gli utenti (letti dal database) e ne aggiorna le monete, in
un'attività separata, al massimo 20 alla volta (`_RESUBSCRIBE_CONCURRENCY`):
in questo modo l'altezza della catena — e con essa un'eventuale estrazione
in corso — continua ad aggiornarsi durante la reiscrizione. Un utente la cui
reiscrizione fallisce viene registrato nei log e saltato; lo riprende il
giro periodico di `DepositReconciler`
([DEP-10](registrazione-e-depositi.md#dep-10)). Se la sessione termina,
la reiscrizione in corso viene interrotta.
*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`.