Require real proof of work in headers and link every advance to the tip (B-74, ELC-11, ELC-12, ELC-22, ELC-23)

A header only had to meet the target it declared for itself, so a fabricated one with
`bits = 0x207fffff` passed half the time, a jump of several blocks was accepted without
being linked to the tip (a fake header at a huge height locked `tip_height` and froze every
draw), and an ordinary one-block reorganisation closed the session on every server.

- draw.py: MAINNET_POW_LIMIT (00000000ffff...ffff); header_meets_its_own_target also
  refuses a claimed target easier than POW_LIMIT.
- ElectrumListener._apply_header is async and takes the active client. A single block must
  chain by prev_hash; a jump downloads the headers in between (blockchain.block.headers,
  at most 2016 per request, ElectrumClient.get_block_headers) and each must carry valid
  work and chain, otherwise HeaderValidationError ends the session.
- The listener keeps the last 10 headers (`_window`), built from the process's first header
  and its 9 predecessors (which must chain into it). A header that does not join the tip is
  walked back to its fork point inside the window and the server's branch is followed
  instead of ending the session; a fork older than the window, or a branch that does not
  end at the announced header, still ends it. The audit row and the pause for deep reorgs
  are B-85; a competing header at the tip's own height is still ignored until then.
- _header_at also serves older heights from the window.

Tests changed because the spec changed: the mainnet powLimit cannot be mined in a test, so
tests/unit/conftest.py swaps in a regtest limit at import time (the new tests put the real
one back); the _apply_header tests are async; the two _run_once tests serve the first
header's 9 predecessors; test_apply_header_skips_linkage_check_across_a_height_gap is
replaced by the jump tests, since that behaviour is exactly what the finding removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 15:45:34 +02:00
1 parent a3c51719bd
commit ca50ef62f3
16 files changed
+494 -159

No files matched your search

@@ -1,6 +1,6 @@
# ADR-26 — Validazione degli header concatenata, senza ricalcolo della difficoltà
- **Stato:** Accettata — implementazione da completare (B-74, B-85)
- **Stato:** Accettata — implementazione da completare (B-85)
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -32,8 +32,7 @@ in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-cr
quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)) e il fatto che il seme dipende anche da `server_seed`.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` ·
*Test:* `tests/unit/test_electrum_listener.py::test_apply_header_rejects_one_that_does_not_chain_from_the_tip`, `tests/unit/test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height`
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessun confronto con
`powLimit`, concatenazione verificata solo sull'avanzamento di un blocco
esatto, un header concorrente alla stessa altezza ignorato, un header che non
si concatena chiude la sessione.
*Test:* `tests/unit/test_electrum_listener.py::test_a_multi_block_jump_is_validated_by_fetching_the_headers_in_between`, `tests/unit/test_electrum_listener.py::test_a_fork_is_walked_back_and_followed_instead_of_ending_the_session`, `tests/unit/test_draw.py::test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target`
*Stato:* da implementare (B-85), per l'header concorrente alla stessa
altezza · *Oggi:* un header concorrente alla stessa altezza della punta è
ignorato invece di essere seguito.
@@ -44,6 +44,7 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* —
*Stato:* da implementare (B-82, B-85) · *Oggi:* nessuna riorganizzazione è
rilevata; un header concorrente è ignorato, uno che non si concatena chiude
la sessione; il pagamento non verifica le monete del round.
*Stato:* da implementare (B-82, B-85) · *Oggi:* una riorganizzazione è
seguita ma non registrata e non mette in pausa la lotteria; un header
concorrente alla stessa altezza è ignorato; il pagamento non verifica le
monete del round.
+5 -4
View File
@@ -216,7 +216,7 @@ sono vuoti e non sono elencati.
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73) |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
@@ -539,9 +539,10 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-74, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena
e non confronta la propria punta con gli altri server.
*Stato:* da implementare (B-85, B-86) · *Oggi:* una riorganizzazione
è seguita ma non registrata né segnalata con una pausa, un header
concorrente alla stessa altezza è ignorato, e il listener non confronta la
propria punta con gli altri server.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
+8 -24
View File
@@ -151,10 +151,6 @@ flowchart TB
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:
@@ -197,11 +193,6 @@ passare a un altro server.
([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
@@ -213,16 +204,8 @@ passare a un altro server.
(`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` ·
*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`
*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
@@ -236,9 +219,8 @@ passare a un altro server.
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`).
*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
@@ -252,9 +234,11 @@ passare a un altro server.
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].
*Codice:* `app/electrum/listener.py:ElectrumListener._follow_reorg` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-85), per l'header con hash diverso alla stessa
altezza della punta · *Oggi:* un header concorrente alla stessa altezza è
ignorato ([ELC-09]); gli altri casi sono seguiti.
- **[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
+7 -9
View File
@@ -324,16 +324,14 @@ implementato il [piano delle correzioni](piano-correzioni.md).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw`
- **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà
reale.** Oggi il bersaglio non è confrontato con il limite di rete: un server
può dichiarare `bits=0x207fffff` e l'header passa **senza alcun lavoro**
(la versione precedente di questa voce parlava di «hardware modesto»,
sottostimando il problema). La concatenazione si verifica solo per un
avanzamento di un blocco esatto, quindi un header falso a un'altezza enorme
blocca `tip_height`, che non regredisce, e con esso ogni estrazione.
reale.** Il bersaglio dichiarato non può essere più facile del limite di rete
(B-74, [ELC-11](connessione-electrum.md#4-header-validati-non-creduti)) e
ogni salto di più blocchi è verificato come catena, quindi un header falso a
un'altezza enorme non blocca più `tip_height`.
- *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)); non protegge dal blocco di `tip_height`.
- *Stato:* da chiudere con B-74.
- *Per chiuderlo:* bersaglio non più facile di `powLimit` e catena
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)); non proteggeva dal blocco di `tip_height`, ora chiuso da B-74.
- *Stato:* chiuso da B-74.
- *Come è chiuso:* bersaglio non più facile di `powLimit` e catena
verificata su ogni salto ([ADR-26](adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md)). Resta accettato dopo il piano: senza
il ricalcolo della difficoltà, un header a difficoltà minima (`powLimit`,
in media 2³² hash) costa una frazione di secondo a un miner ASIC e pochi
+3 -4
View File
@@ -66,10 +66,9 @@ ricompensa di quel blocco.
confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)).
*Oggi* (B-73): il seme è l'hash del primo blocco visto dopo la chiusura,
senza segreto né impegno, e l'estrazione è manipolabile da chi mina.
- **Header falsi.** Oggi l'unico controllo di lavoro su un header è che il
suo hash rispetti il bersaglio che l'header stesso dichiara nel campo
`bits`: poiché è chi fabbrica l'header a scegliere `bits`, un header mai
minato con `bits = 0x207fffff` lo passa con probabilità 1/2. Dopo B-74 il
- **Header falsi.** Il bersaglio che un header dichiara nel campo `bits` lo sceglie chi
fabbrica l'header (con `bits = 0x207fffff` un header mai minato passava con
probabilità 1/2): per questo dal B-74 il
bersaglio non può essere più facile del powLimit di PLM, `0x1d00ffff`
([ELC-11](connessione-electrum.md#4-header-validati-non-creduti)), e ogni
salto di più blocchi è verificato come catena. Anche al powLimit un header
+3 -3
View File
@@ -222,7 +222,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | *da registrare* | `test_electrum_listener.py`, `test_draw.py` | ELC |
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX |
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR |
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | *da registrare* | `test_tx_versions.py`, `test_migrations.py`, `test_broadcast.py` | TX |
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (5 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (4 critici,
6 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.