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

393 lines
25 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).
Questo 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)).
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[:<modo>]`, 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), `host:porta:<modo>` con `<modo>` tra `ssl`/`tls` (TLS) e
`notls`/`plain` (in chiaro), oppure `host:porta:pin=<sha256>` (TLS con
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali. 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`
*Stato:* da implementare (B-84), per la forma `pin=` · *Oggi:* il terzo
campo accetta solo `ssl`/`tls`/`notls`/`plain`; `pin=…` è una voce
malformata.
- **[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. Il certificato non è
verificato contro un'autorità (i server Electrum usano spesso certificati
autofirmati), ma può essere **fissato**: per una voce con `pin=<sha256>`
([ELC-01]), e per il server principale con `ELECTRUM_CERT_PIN`, lo SHA-256
del certificato del server in formato DER deve coincidere con il valore
configurato, altrimenti la connessione fallisce come un server
irraggiungibile e si passa al successivo ([ELC-02]). Senza pin 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` · *Test:* —
*Stato:* da implementare (B-84) · *Oggi:* nessun certificato viene mai
verificato; non esiste un modo di fissarlo.
- **[ELC-19]** Il server **rifiuta di avviarsi** se, dopo l'eliminazione dei
duplicati, l'elenco contiene meno di **2 server di riserva** oltre al
principale: con meno di tre server il quorum di [ELC-14] non può mai avere
due risposte indipendenti da quella del server attivo. L'unica eccezione è
`ALLOW_NO_QUORUM=true`, **solo per lo sviluppo**: il server parte, scrive un
avviso nei log a ogni avvio, e le corroborazioni seguono la regola ridotta
di [ELC-14].
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* —
*Stato:* da implementare (B-84) · *Oggi:* un `ELECTRUM_FALLBACK_SERVERS`
vuoto (il valore di `.env.example`) è accettato in silenzio e disattiva
ogni corroborazione.
## 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`
- **[ELC-20]** Una riga di risposta può essere lunga fino a **16 MiB**
(16 777 216 byte). Una riga più lunga, o una riga che non è JSON valido,
è un errore **della richiesta**, non della connessione: la riga viene
scartata fino al suo a capo e la connessione resta aperta. La richiesta a
cui rispondeva fallisce subito con un errore se il suo `id` si legge nei
primi o negli ultimi 256 byte della riga; altrimenti fallisce allo scadere
di [ELC-04].
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._read_loop` · *Test:* —
*Stato:* da implementare (B-75) · *Oggi:* il lettore ha il limite
predefinito di asyncio (64 KiB); una riga più lunga, o non JSON, interrompe
`_read_loop` e chiude l'unica connessione. La cronologia dell'indirizzo del
montepremi supera 64 KiB dopo 800 transazioni (stima di B-75), e da lì ogni controllo
delle conferme la fa cadere.
## 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 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. Un
errore nell'aggiornamento di **un** utente dopo una notifica del suo
indirizzo non termina la sessione
([DEP-16](registrazione-e-depositi.md#deposito)).
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
aggiornamenti concorrenti) termina la sessione.
- **[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`
- **[ELC-21]** **Server fermo.** Un server può restare connesso e rispondere
ai ping senza più seguire la catena. A ogni ping di [ELC-05], se l'ultimo
header accettato ha più di **360 secondi**, il listener chiede la punta
(`blockchain.headers.subscribe`) a ciascuno degli altri server configurati,
con i tempi di [ELC-14]; se almeno uno riporta un'altezza **maggiore** di
quella nota, la sessione termina e si passa al server successivo
nell'elenco ([ELC-02]). Se nessuno riporta un'altezza maggiore (la rete
non ha prodotto blocchi) la sessione continua.
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive` · *Test:* —
*Stato:* da implementare (B-86) · *Oggi:* nulla confronta l'avanzamento
della punta con gli altri server; un server fermo che risponde ai ping
resta il server attivo indefinitamente.
## 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. Il
listener tiene gli **ultimi 10 header** accettati ([ELC-22]): è ciò che gli
permette di riconoscere una riorganizzazione e seguirla invece di scambiarla
per un server ostile.
```mermaid
flowchart TB
A["Header annunciato<br/>all'altezza H"] --> B{"Ha il contenuto<br/>binario?"}
B -- "No" --> I1["Ignorato (ELC-10)"]
B -- "Sì" --> C{"H rispetto alla<br/>punta T"}
C -- "H < T" --> I2["Ignorato (ELC-08)"]
C -- "H = T, stesso hash" --> I3["Ignorato (ELC-09)"]
C -- "H = T, hash diverso" --> F
C -- "H > T" --> D["Si scaricano gli header<br/>T+1 … H−1 (ELC-12)"]
D --> E{"80 byte, bits non più<br/>facili del powLimit, hash<br/>entro il bersaglio? (ELC-11)"}
E -- "No" --> X["HeaderValidationError:<br/>sessione chiusa,<br/>server successivo"]
E -- "Sì" --> G{"T+1 si aggancia<br/>alla punta T?"}
G -- "Sì, e la catena<br/>è collegata fino a H" --> OK["Punta = H<br/>finestra aggiornata (ELC-13)"]
G -- "No" --> F["Ricerca del punto di<br/>biforcazione negli ultimi<br/>10 header (ELC-23)"]
F -- "Trovato" --> R["Si segue il nuovo ramo<br/>chain_reorg_detected;<br/>pausa se profondità ≥ 2 (ELC-24)"]
F -- "Non trovato" --> X
```
*Comportamento atteso.* Oggi un salto di più blocchi non viene collegato, la
difficoltà non è confrontata con il powLimit, e un header che non si aggancia
chiude la sessione.
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. Un server rimasto indietro non sposta la punta, e un
ramo più corto del nostro non viene seguito. Il rovescio va detto: se un
header falso a un'altezza molto alta venisse accettato, nessun header
onesto (più basso) lo sostituirebbe più; la punta resta bloccata fino al
riavvio del processo (l'altezza è solo in memoria). Con [ELC-11] e
[ELC-12] quell'header deve portarsi dietro una catena collegata di header
con lavoro valido; e comunque non sceglie il vincitore, perché il blocco di
estrazione è letto a un'altezza fissa e confermato dal quorum ([ELC-15]):
l'estrazione attende, e l'attesa è visibile (`draw_stalled`,
`GET /health/ready`, [ELC-29]).
- **[ELC-09]** un header per l'altezza **già nota** con lo **stesso** hash
(lo stesso blocco riannunciato). Un header per l'altezza già nota con un
hash **diverso** non è ignorato: è una riorganizzazione in punta e si
segue secondo [ELC-23].
*Stato:* da implementare (B-85), per il caso con hash diverso · *Oggi:*
anche un hash diverso è ignorato e quello già adottato non viene mai
sostituito.
- **[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`
I controlli seguenti invece, se falliscono, **chiudono la sessione** e fanno
passare a un altro server.
- **[ELC-11]** L'header deve essere lungo 80 byte; il bersaglio che dichiara
(campo `bits`) non deve essere più facile del **powLimit** di PLM mainnet,
`00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff`
(in forma compatta `0x1d00ffff`, lo stesso di Bitcoin; fonte:
`palladiumcore/src/chainparams.cpp`, `consensus.powLimit` della rete
principale); e il suo hash deve rispettare quel bersaglio. Il controllo
toglie la contraffazione **gratuita**, non quella economica: anche al
powLimit un header costa in media 2³² hash, 43 µs per un ASIC da
100 TH/s. Ciò che protegge davvero l'estrazione è il quorum sul blocco
([ELC-15]).
*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`
*Stato:* da implementare (B-74), per il confronto con il powLimit ·
*Oggi:* si verifica solo che l'hash rispetti il bersaglio che l'header
stesso dichiara. Poiché è chi fabbrica l'header a scegliere `bits`, il
controllo non respinge un header mai minato: con `bits = 0x207fffff`
(bersaglio `0x7fffff · 2²³²`, appena sotto 2²⁵⁵) lo passa un header casuale con probabilità 1/2.
- **[ELC-12]** **Ogni** avanzamento della punta deve essere collegato alla
punta corrente. Un avanzamento di un blocco deve avere come `prev_hash`
l'hash della punta. Per un avanzamento di più blocchi, dalla punta `T` a
`H`, si scaricano dal server attivo gli header `T+1 … H−1`
(`blockchain.block.headers`, a blocchi di al più 2016 header per
richiesta); ciascuno deve superare [ELC-11] e agganciarsi al precedente, e
`H` all'ultimo. Un header scaricato che non supera [ELC-11], o una catena
scaricata spezzata, chiude la sessione. Se invece è il **primo** passo
(`T+1`, o `H` stesso per un avanzamento di un blocco) a non agganciarsi
alla punta, non si chiude la sessione: è una possibile riorganizzazione e
si segue [ELC-23].
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/client.py:ElectrumClient.request` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74) · *Oggi:* il collegamento si verifica solo
per un avanzamento di un blocco; un salto di più blocchi è accettato con il
solo controllo di difficoltà. Un avanzamento di un blocco che non si
aggancia chiude la sessione: per questo una normale riorganizzazione di un
blocco (la nostra punta `N` diventa orfana) fa chiudere la sessione su
**ogni** server — ciascuno annuncia `N+1` sul nuovo ramo, che non si
aggancia alla nostra `N` — finché l'arrivo di `N+2` non rende l'avanzamento
un salto di due blocchi, accettato senza controllo di collegamento.
- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della
punta (mai uno senza l'altro) e la finestra di [ELC-22]. 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`
- **[ELC-22]** Il listener tiene in memoria gli **ultimi 10 header**
accettati (altezza → contenuto), punta compresa. Il primo header del
processo non ha nulla a cui agganciarsi ed è accettato con il solo
[ELC-11]; subito dopo si scaricano i 9 header precedenti, che devono
superare [ELC-11] ed essere collegati fra loro e al primo, altrimenti la
sessione si chiude. La finestra sopravvive alle riconnessioni, non ai
riavvii.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._run_once` · *Test:* —
*Stato:* da implementare (B-85) · *Oggi:* si tiene solo la punta
(`tip_height`, `tip_header_hex`).
### Riorganizzazioni
- **[ELC-23]** Quando un header non si aggancia alla punta ([ELC-12]) o ne
sostituisce l'hash alla stessa altezza ([ELC-09]), il listener cerca il
**punto di biforcazione**: chiede al server attivo gli header delle altezze
della finestra, dalla punta verso il basso, fino a 10, e prende la più alta
altezza in cui l'hash del server coincide con il nostro. Se lo trova, il
ramo del server dal punto di biforcazione fino al nuovo header deve
superare [ELC-11] ed essere collegato; allora sostituisce la finestra sopra
il punto di biforcazione e il nuovo header diventa la punta. Se non lo
trova entro 10 header (una biforcazione più profonda, o un server su
un'altra catena), la sessione si chiude come per [ELC-11].
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · *Test:* —
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessuna riorganizzazione è
rilevata né seguita; vedi l'*Oggi* di [ELC-12].
- **[ELC-24]** Ogni riorganizzazione seguita scrive l'evento
`chain_reorg_detected` con la profondità (numero dei nostri blocchi
sostituiti, cioè vecchia altezza della punta meno altezza del punto di
biforcazione), l'altezza del punto di biforcazione, la vecchia e la nuova
altezza della punta e i due hash di punta. Con profondità **≥ 2** la
lotteria va in pausa (`paused = true`, `paused_reason = 'reorg'`, vedi
[modello-dati.md](modello-dati.md) B-85) finché l'operatore non la
riprende: come ogni pausa, impedisce solo l'apertura del round successivo.
Con profondità 1 si scrive solo l'evento.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/db/models.py:RoundConfig` · *Test:* —
*Stato:* da implementare (B-85) · *Oggi:* l'evento non esiste e nulla mette
in pausa la lotteria da solo.
## 5. Il quorum
Ogni decisione che sposta denaro in base a una risposta della rete viene
confermata dagli **altri** server configurati, mai dal solo server attivo
(che è proprio quello che un attaccante controllerebbe).
Oggi non è ancora così: solo le tre verifiche [ELC-15]–[ELC-17] sono
corroborate. Le conferme delle transazioni (`app/tx/confirmation.py:poll_once`),
l'abbandono di una transazione (`app/tx/reconcile.py:reconcile_once`), il
reintegro di una moneta ricomparsa
(`app/deposits/service.py:reinstate_reappeared_utxos`) e la lettura delle
monete del montepremi per il pagamento
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basano sul solo
server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27].
- **[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. Un server che non risponde o dà errore non conta. Passa se
hanno risposto **almeno 2** server **e** concorda la **maggioranza stretta
di quelli che hanno risposto**; con meno di 2 risposte **fallisce**: una
rete irraggiungibile, o un solo server raggiungibile, non prova nulla.
Con `ALLOW_NO_QUORUM=true` ([ELC-19]) vale invece la regola ridotta: senza
altri server configurati la verifica passa, altrimenti basta la
maggioranza stretta delle risposte, anche di una sola.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-84) · *Oggi:* vale per tutti la regola ridotta.
Senza server di riserva ogni verifica passa; con server di riserva la
maggioranza è calcolata su chi ha risposto, quindi **una risposta su una
decide**: se è raggiungibile un solo altro server, la sua parola basta.
Nessuna risposta fa fallire la verifica.
| ID | Prima di… | Si verifica che… |
|---|---|---|
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che ciascun server restituisce per l'altezza di estrazione (`blockchain.block.header`) abbia lo stesso hash; regole complete in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione) (B-73) |
| **[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, a un'altezza che le dà almeno le conferme richieste ([DEP-03](registrazione-e-depositi.md#deposito)) contate sulla nostra punta |
| **[ELC-25]** | reintegrare una moneta data per spesa fuori | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0), come per un accredito |
| **[ELC-26]** | segnare confermata una transazione | l'altro server restituisca per il txid una prova merkle (`blockchain.transaction.get_merkle`) che, ricalcolata, dà la radice merkle dell'header di quell'altezza; l'header è quello della finestra ([ELC-22]) o, se più vecchio, quello su cui concorda il quorum come in [ELC-15]. Regole della conferma in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#conferma) (B-83) |
| **[ELC-27]** | costruire il pagamento di un round | l'altro server elenchi come non spese le monete delle scommesse del round all'indirizzo del suo montepremi; regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) (B-82) |
| **[ELC-28]** | abbandonare una transazione | l'altro server riporti un suo ingresso speso in un blocco da un txid diverso, oppure non conosca nessuno dei suoi txid; regole in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione) (B-78) |
*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
Stato delle righe della tabella:
- [ELC-15] — *Stato:* da implementare (B-73) · *Oggi:* si corrobora l'header
del primo blocco che il server attivo ha annunciato dopo la chiusura,
chiedendo solo agli altri server; se la verifica fallisce si passa al blocco
successivo invece di richiedere la stessa altezza.
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
maggiore di 0 (1 conferma).
- [ELC-25] — *Stato:* da implementare (B-83) · *Oggi:* il reintegro si basa
sulla sola risposta del server attivo.
- [ELC-26] — *Stato:* da implementare (B-83) · *Oggi:* una transazione è
confermata quando la cronologia dell'indirizzo sul server attivo la riporta
con altezza maggiore di 0.
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
fra le monete che il server attivo elenca per il montepremi.
- [ELC-28] — *Stato:* da implementare (B-78) · *Oggi:* si abbandona quando la
cronologia dell'indirizzo sul server attivo non contiene il txid.
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)), una moneta ricomparsa non
verificata resta fuori ([DEP-08](registrazione-e-depositi.md#deposito));
- una conferma non verificata lascia la transazione in attesa, e si riprova
al controllo successivo;
- un blocco di estrazione non verificato fa attendere e richiedere la stessa
altezza, e lo si registra in audit (`draw_header_corroboration_failed`,
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
L'**invio** di una transazione non è una corroborazione ma usa gli stessi
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
inviata anche a ogni server di riserva, al meglio. La regola è in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-76,
B-83); oggi l'invio va al 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`
## Stato della connessione
- **[ELC-29]** Il listener espone lo stato della connessione: se è connesso,
il server attivo, l'altezza della punta e da quanti secondi è stato
accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non
connesso o se l'ultimo header ha più di 360 secondi,
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
corrente resta visibile anche nella barra di stato della catena di `/` (da
`chain_tip_height` di `GET /rounds/current`).
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
*Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
mostra solo l'altezza.