186 lines
10 KiB
Markdown
186 lines
10 KiB
Markdown
# 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`.
|