The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
349 lines
22 KiB
Markdown
349 lines
22 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`, `app/electrum/listener.py:ElectrumListener._check_not_stalled` ·
|
||
*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. 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
|
||
```
|
||
|
||
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].
|
||
- **[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`
|
||
- **[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/listener.py:ElectrumListener._fetch_headers`, `app/electrum/client.py:ElectrumClient.get_block_headers` ·
|
||
*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) 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._adopt_first_header`, `app/electrum/listener.py:ElectrumListener._extend_window` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
|
||
### 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._follow_reorg` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
- **[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.
|
||
L'evento è scritto **prima** che la finestra si sposti: se la scrittura
|
||
fallisce la sessione termina e la stessa biforcazione, annunciata dal server
|
||
successivo, viene registrata allora (nessuna riorganizzazione è seguita
|
||
senza traccia).
|
||
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg`, `app/electrum/listener.py:ElectrumListener._follow_reorg`, `app/db/models.py:RoundConfig` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`
|
||
|
||
## 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ì per una sola decisione: la lettura delle monete del
|
||
montepremi per il pagamento
|
||
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basa sul solo
|
||
server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
|
||
([ELC-26]), il reintegro di una moneta ricomparsa ([ELC-25]), l'abbandono
|
||
([ELC-28]) e l'invio ([TX-04], [TX-21]) usano già gli altri server.
|
||
|
||
- **[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, **attivo compreso**, restituisce per l'altezza di estrazione (`blockchain.block.header`, 80 byte con lavoro valido) abbia lo stesso hash, con almeno 2 risposte dagli altri server e la maggioranza stretta di chi ha risposto; 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.agreed_block_hash`,
|
||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
|
||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
|
||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement`,
|
||
`app/electrum/listener.py:ElectrumListener.confirmation_proven`,
|
||
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
|
||
|
||
Stato delle righe della tabella:
|
||
|
||
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
|
||
maggiore di 0 (1 conferma).
|
||
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
|
||
fra le monete che il server attivo elenca per il montepremi.
|
||
|
||
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).
|
||
|
||
## 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.
|