place_bet and request_withdrawal released everything on any exception from the broadcast, including the 15s timeout and a connection dropped after the write. The tx may have propagated: a bet's sats landed in the pool with no participant, a withdrawal left while its row said failed and its coins were spendable again. - client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain ElectrumError. - tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. A refusal is verified first: ElectrumListener.transaction_known asks blockchain.transaction.get of the active server and every other one (10s each) — any server returning the tx means accepted; the active server plus MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps the reduced rule); too few answers is uncertain. "already known" wording is only a shortcut that skips the check. With no network to ask, a refusal is uncertain. - place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal releases; uncertain leaves the rows building for the reconciler, publishes SSE and POST /bets and POST /withdrawals answer 202 with status building; "already known" promotes a payout (payout_sent) like any accepted broadcast. - An accepted transaction is fanned out to every other server (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF replacement in bump_fee is left for B-77, which rewrites that function. Tests changed because the spec changed: the existing "refused broadcast" tests raised a bare RuntimeError (now an uncertain outcome) and are made explicit refusals (rpc_error) with a network that denies the tx; direct route calls pass a Response; FakePayoutListener gains the network surface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
382 lines
24 KiB
Markdown
382 lines
24 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 [limiti-noti.md](limiti-noti.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 (maiuscole accettate, conservate in
|
||
minuscolo); `pin=` può seguire anche `<modo>` con TLS, mai `notls`/`plain`
|
||
(non c'è un certificato da fissare). 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. 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`, `app/electrum/client.py:ElectrumClient._check_pin`, `app/main.py:_make_electrum_client` ·
|
||
*Test:* `tests/unit/test_electrum_client.py`
|
||
- **[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_quorum_servers`, `app/main.py:lifespan` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
|
||
## 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:* `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 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`
|
||
|
||
| 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 anche a tutti gli altri server, salvo la sostituzione
|
||
RBF di `bump_fee` (B-77).
|
||
|
||
## 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.
|