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