A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
349 lines
23 KiB
Markdown
349 lines
23 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).
|
||
|
||
Vale per ogni decisione di questo tipo: le monete del montepremi per il
|
||
pagamento ([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 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.verify_pool_outpoints`,
|
||
`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`, `tests/unit/test_deposit_confirmations.py`, `tests/unit/test_pool_verification.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)), 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));
|
||
- le monete del montepremi non verificate fanno terminare il tentativo di
|
||
pagamento con `payout_failed` (`pool verification unavailable`), senza
|
||
escludere nessun partecipante ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)).
|
||
La verifica ([ELC-27]) chiede a ogni altro server l'elenco delle monete
|
||
dell'indirizzo del round: per ogni uscita di scommessa una maggioranza
|
||
stretta di chi risponde dice *presente* (stesso importo, confermata) o
|
||
*assente* (non elencata, o con un altro importo); un'uscita elencata solo
|
||
nel mempool, o senza maggioranza, non dà alcun verdetto e si riprova.
|
||
|
||
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.
|