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>
10 KiB
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. La configurazione
(.env) è descritta in setup.md, i rischi accettati in
sicurezza.md e 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) oppurehost:porta:<modo>, con<modo>trassl/tls(TLS) enotls/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). 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_SERVERSvuoto: in produzione conviene configurarne più d'uno (vedi 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 - Nessun server di riserva configurato → la verifica passa. È il rischio
accettato di un
| 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);
- una moneta sparita non verificata resta nel saldo (DEP-07);
- un blocco non verificato fa attendere il blocco seguente, e lo si registra
in audit (
draw_header_corroboration_failed, RND-16).
Il reintegro di una moneta ricomparsa (DEP-08) 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 diDepositReconciler(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.