diff --git a/BUGS.md b/BUGS.md index 7cc8676..0d9d6e1 100644 --- a/BUGS.md +++ b/BUGS.md @@ -166,14 +166,6 @@ winner's own bet is gone the payout is held (`payout_blocked`) and the lottery p ## High -### B-85 — Reorgs are neither followed nor surfaced - -Nothing detects a reorg; a 1-block reorg is treated as a hostile server (B-74). - -Fix: the listener keeps the last 10 headers; a fork is followed to its fork point, a -`chain_reorg_detected` audit row records depth and heights, and a depth ≥ 2 pauses -the lottery (`paused_reason = 'reorg'`) until the operator resumes it. - ### B-86 — A connected but stalled server is never replaced `ping` keeps succeeding while the server stops following the chain; nothing compares diff --git a/CLAUDE.md b/CLAUDE.md index 26f56a8..03f3678 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 570 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 576 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 570 tests +python -m pytest # all 576 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` @@ -157,7 +157,7 @@ One connection serves everything — deposit credits, broadcasts, confirmations, - **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn. - **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently. - **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive. -- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at the tip's own height with a different hash (until B-85 follows it; `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63. +- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. A followed reorg writes `chain_reorg_detected` *before* the window moves (`_record_reorg`, B-85), and a depth of 2 or more pauses the lottery with `paused_reason = 'reorg'` in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused *without* ending the session (B-64): one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63. - **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep. On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep. diff --git a/README.md b/README.md index 188512f..1649aac 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -570 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +576 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 1526a98..39076b3 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -7,6 +7,8 @@ from embit.transaction import Transaction from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker +from app.audit.log import write_audit_log +from app.db.base import begin_immediate from app.db.models import User from app.deposits.service import ( credit_confirmed_utxos, @@ -28,6 +30,7 @@ from app.rounds.draw import ( header_prev_hash, ) from app.rounds.events import broadcaster +from app.rounds.pause import pause_automatically logger = logging.getLogger(__name__) @@ -52,6 +55,10 @@ _RESUBSCRIBE_CONCURRENCY = 20 # a hostile server. Also the deepest fork it will follow. _HEADER_WINDOW = 10 +# A reorganisation at least this deep pauses the lottery (B-85, ELC-24): one block is +# ordinary on any chain, two or more is worth a look before a new round opens. +_REORG_PAUSE_DEPTH = 2 + # `blockchain.block.headers` serves at most this many headers per request (ELC-12). _MAX_HEADERS_PER_REQUEST = 2016 @@ -339,14 +346,11 @@ class ElectrumListener: if self.tip_header_hex is not None and height == self.tip_height: if header_hex == self.tip_header_hex: return # the same block announced again - # B-64: a second header for the height we hold one for — a reorg at the tip, or a - # server disagreeing with the rest. Not replaced under us (B-85 follows it, - # ELC-09); corroborate_header refuses to seed a draw from an orphan anyway. - logger.warning( - "ignoring a competing header at the current tip height %s " - "(reorg at the tip, or a server disagreeing with the rest)", - height, - ) + # B-85 (ELC-09): another block at the height we hold — a reorg at the tip. It is + # followed like any other fork (it must bring the work and the chain to show for + # it), not ignored: ignoring it left us on an orphan for as long as the tip + # stood, and a draw seeded from it would have been refused by the quorum. + await self._follow_reorg(height, header_hex, client) return if not header_meets_its_own_target(header_hex): @@ -460,17 +464,37 @@ class ElectrumListener: raise HeaderValidationError(f"the server's branch does not end at the header it announced at height {height}") self._check_chained(branch, header_hex_to_block_hash(self._window[fork_height])) old_height, old_hex = self.tip_height, self.tip_header_hex - self._extend_window([(fork_height + 1 + i, h) for i, h in enumerate(branch)]) - await self._reorg_followed(old_height, old_hex, fork_height) - - async def _reorg_followed(self, old_height: int, old_hex: str, fork_height: int) -> None: - logger.warning( - "followed a chain reorganisation: tip %s -> %s, fork at height %s (depth %s)", - old_height, - self.tip_height, - fork_height, - old_height - fork_height, + # Recorded *before* the window moves: if the write fails the session ends, the next + # server announces the same fork against an unchanged window, and the event is + # written then — a reorg is never followed without a trace. + await self._record_reorg( + depth=old_height - fork_height, + fork_height=fork_height, + old_tip_height=old_height, + old_tip_hash=header_hex_to_block_hash(old_hex), + new_tip_height=height, + new_tip_hash=header_hex_to_block_hash(header_hex), ) + self._extend_window([(fork_height + 1 + i, h) for i, h in enumerate(branch)]) + + async def _record_reorg(self, **payload) -> None: + """B-85 (ELC-24, AUD-38): the audit row of a followed reorganisation; a depth of 2 + or more also pauses the lottery (`paused_reason = 'reorg'`), in the same transaction + as the row and as the `lottery_paused` one. Depth 1 is only observed: a one-block + reorganisation is ordinary on any chain and touches no deposit.""" + logger.warning( + "followed a chain reorganisation of depth %s: fork at height %s, tip %s -> %s", + payload["depth"], + payload["fork_height"], + payload["old_tip_height"], + payload["new_tip_height"], + ) + async with self._session_factory() as session: + await begin_immediate(session) + await write_audit_log(session, "chain_reorg_detected", payload) + if payload["depth"] >= _REORG_PAUSE_DEPTH: + await pause_automatically(session, "reorg") + await session.commit() async def _corroborate_majority( self, diff --git a/docs/adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md b/docs/adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md index 15d43a8..4f4c91f 100644 --- a/docs/adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md +++ b/docs/adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md @@ -1,6 +1,6 @@ # ADR-26 — Validazione degli header concatenata, senza ricalcolo della difficoltà -- **Stato:** Accettata — implementazione da completare (B-85) +- **Stato:** Accettata - **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,7 +32,4 @@ 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_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. +*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_electrum_listener.py::test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow`, `tests/unit/test_draw.py::test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target` diff --git a/docs/adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md b/docs/adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md index 63337f7..33914ab 100644 --- a/docs/adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md +++ b/docs/adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md @@ -1,6 +1,6 @@ # ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle -- **Stato:** Accettata — implementazione da completare (B-82, B-85) +- **Stato:** Accettata — implementazione da completare (B-82) - **Area:** Conferme e fiducia nella rete - **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*. @@ -42,9 +42,7 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento) irrecuperabile e limitano il danno a ciò che l'attaccante ha rimosso ([LIM-24]). -*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:* 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. +*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` · +*Test:* `tests/unit/test_electrum_listener.py` +*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete +del round: le prime due misure (rilevare, fermare) sono attive. diff --git a/docs/architettura.md b/docs/architettura.md index 01f2f94..e7316b1 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -528,7 +528,7 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). - il listener conserva gli ultimi 10 header ricevuti; un salto di più blocchi scarica e valida gli header intermedi, e un header che si concatena a uno di quelli conservati ma non alla punta è una - riorganizzazione da seguire, non la fine della sessione (B-74, B-85); + riorganizzazione da seguire, non la fine della sessione; - una sessione connessa la cui punta non avanza da 360s, mentre un altro server ne riporta una più alta, viene chiusa e si passa al server successivo (B-86); @@ -539,9 +539,7 @@ 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-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 + *Stato:* da implementare (B-86) · *Oggi:* 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 diff --git a/docs/audit-log.md b/docs/audit-log.md index 0268b2f..7bc75f9 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -43,9 +43,10 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in `app/rounds/scheduler.py:RoundScheduler._log_payout_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`, `app/rounds/scheduler.py:RoundScheduler._tick`, - `app/electrum/listener.py:ElectrumListener._apply_header` - *Stato:* da implementare (B-85, B-87, B-90, B-96), per i quattro eventi - nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro. + `app/electrum/listener.py:ElectrumListener._record_reorg` + *Stato:* da implementare (B-87, B-90, B-96), per tre degli eventi + nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro e + `chain_reorg_detected`. - **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna `payload_json`. `created_at` è impostato dall'applicazione, non dal @@ -210,14 +211,14 @@ flowchart LR ogni pausa, anche se la lotteria era già in pausa. Stessa transazione di `paused = true`. *Codice:* `app/api/routes/admin.py:pause_lottery`, `app/main.py:lifespan`, - `app/electrum/listener.py:ElectrumListener._apply_header`, + `app/electrum/listener.py:ElectrumListener._record_reorg`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/reconcile.py:reconcile_once` · - *Test:* `tests/unit/test_pause_reason.py` - *Stato:* da implementare (B-82, B-85, B-90) · *Oggi:* scritto da - `POST /admin/pause` (`operator`) e dal riconciliatore (`resurrected_tx`); - `reorg`, `payout_blocked` e l'avvio con `START_PAUSED` non lo scrivono - ancora. + *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py` + *Stato:* da implementare (B-82, B-90) · *Oggi:* scritto da + `POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`) e dal + listener (`reorg`); `payout_blocked` e l'avvio con `START_PAUSED` non lo + scrivono ancora. - **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload `{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la @@ -632,10 +633,8 @@ flowchart LR `lottery_paused` con `reason = "reorg"` ([AUD-11]). Una biforcazione più profonda di 10 header non viene seguita e non scrive questo evento (la sessione si chiude). Nessuna logica rilegge la riga. - *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · - *Test:* — - *Stato:* da implementare (B-85) · *Oggi:* nessuna riorganizzazione è - rilevata; l'evento non esiste. + *Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · + *Test:* `tests/unit/test_electrum_listener.py` ## Cosa non viene registrato diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index 35b4069..8f16f7a 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -97,9 +97,8 @@ richiesta di scommessa (vedi *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` · *Test:* `tests/unit/test_rounds_service.py` - *Stato:* da implementare (B-85, B-79) · *Oggi:* l'unica pausa è quella - manuale (`paused`) e `rounds_can_open` controlla solo che `fee_address` non - sia vuoto. + *Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo + che `fee_address` non sia vuoto. - **[RND-05]** All'apertura il round **fotografa** i parametri correnti che lo governano: durata (`round_duration_seconds`, 600s predefiniti), pausa successiva (`round_cooldown_seconds`, 30s predefiniti), costo della diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index c4d0e71..0066fdb 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -169,9 +169,6 @@ chiudere la sessione, perché non indicano un server ostile: (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. @@ -236,9 +233,6 @@ passare a un altro server. 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` - *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 @@ -248,9 +242,12 @@ passare a un altro server. [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. + 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 diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index 530cbbc..b9b848c 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -36,7 +36,7 @@ implementato il [piano delle correzioni](piano-correzioni.md). | [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 | | [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide | | [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico | -| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85; resta [LIM-24] | +| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85 (la pausa e il registro sono attivi; resta il pagamento, B-82); resta [LIM-24] | | [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 (resta la lettura delle monete del montepremi, B-82) | | [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] | | [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima | @@ -250,7 +250,7 @@ implementato il [piano delle correzioni](piano-correzioni.md). su una moneta scomparsa è rifiutata dalla rete e annullata; per una scommessa già confermata non esiste alcuna mitigazione (la versione precedente di questa voce diceva il contrario). - - *Stato:* da chiudere con B-82 e B-85. + - *Stato:* da chiudere con B-82; le riorganizzazioni sono già rilevate, registrate e (se profonde) in pausa (B-85). - *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a 120 ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)); riorganizzazioni rilevate, registrate e, se profonde, con pausa; monete del round verificate prima del pagamento, con i diff --git a/docs/modello-dati.md b/docs/modello-dati.md index f80950d..641f819 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -826,8 +826,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). [DAT-69], che scrivono la propria voce di audit. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-82, B-85) · *Oggi:* l'unica pausa automatica è - `resurrected_tx` (B-78); quelle di B-82 e B-85 non esistono ancora. + *Stato:* da implementare (B-82) · *Oggi:* le pause automatiche sono + `resurrected_tx` (B-78) e `reorg` (B-85); quella di B-82 non esiste ancora. - **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di conferme dopo cui un output che paga un utente si accredita ([DAT-10]); @@ -864,12 +864,12 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). successivo. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · - `app/electrum/listener.py:ElectrumListener._apply_header` · + `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · - `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py` - *Stato:* da implementare (B-82, B-85, B-90) · *Oggi:* si scrivono solo - `operator` (`POST /admin/pause`) e `resurrected_tx` (il riconciliatore); - `reorg`, `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive. + `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py` + *Stato:* da implementare (B-82, B-90) · *Oggi:* si scrivono + `operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore) e + `reorg` (il listener); `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive. - **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la diff --git a/docs/operativita.md b/docs/operativita.md index e7ebb77..85d3e03 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -815,13 +815,13 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). *Codice:* `app/api/routes/admin.py:pause_lottery`, `app/api/routes/admin.py:resume_lottery`, `app/rounds/service.py:open_new_round_if_needed`, - `app/electrum/listener.py:ElectrumListener._apply_header`, + `app/electrum/listener.py:ElectrumListener._record_reorg`, `app/tx/confirmation.py:poll_once`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · - *Test:* — - *Stato:* da implementare (B-85, B-82, B-90) · *Oggi:* il sistema mette in - pausa la lotteria solo per `resurrected_tx` (B-78); `reorg` e - `payout_blocked` non esistono ancora. + *Test:* `tests/unit/test_electrum_listener.py` + *Stato:* da implementare (B-82, B-90) · *Oggi:* il sistema mette in + pausa la lotteria per `resurrected_tx` (B-78) e `reorg` (B-85); + `payout_blocked` non esiste ancora. ### Intervento manuale sui fondi diff --git a/docs/sicurezza.md b/docs/sicurezza.md index 15db58b..5e3006c 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -92,8 +92,8 @@ Restano **accettati**, e descritti in riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82, B-85). Il denaro che un attaccante riesce a sottrarre è perso per la - piattaforma. *Oggi:* depositi a 1 conferma, nessuna riorganizzazione - rilevata, nessuna verifica prima del pagamento. + piattaforma. *Oggi:* depositi a 1 conferma e nessuna verifica prima + del pagamento (B-82); le riorganizzazioni sono rilevate e, da profondità 2, mettono in pausa. ## Inventario dei segreti diff --git a/docs/storico-bug.md b/docs/storico-bug.md index 767dc0c..05a07a5 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -233,7 +233,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND | | B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP | | B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC | -| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | **aperto** | — | ELC, RND | +| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND | | B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | **aperto** | — | ELC, OPS | | B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX | | B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC | @@ -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, B-74. + 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, B-85. - **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 (4 critici, - 6 alti, 6 medi), da implementare nell'ordine di + 5 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index 79cb244..ab4c332 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -18,7 +18,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base -from app.db.models import User, UtxoEvent +from app.db.models import AuditLog, RoundConfig, User, UtxoEvent from app.electrum.client import ElectrumEndpoint from app.electrum.listener import ElectrumListener from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target @@ -247,26 +247,6 @@ async def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched -async def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(session_factory, caplog): # B-64 - """The linkage check only fires on a single-block advance, so a header at the - height we already hold one for used to be applied on nothing but its own - self-consistency — replacing the very hash a draw may be about to be seeded - with. Whichever it is (a reorg at the tip, or a server disagreeing with the - rest), the hash committed to for a height is not swapped under us; if ours is - the orphan, corroborate_header refuses to draw from it anyway.""" - listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) - header_100 = _mine_header("00" * 32) - await listener._apply_header({"height": 100, "hex": header_100}) - - competing_100 = _mine_header("11" * 32) # same height, well-formed, different block - assert competing_100 != header_100 - with caplog.at_level(logging.WARNING): - await listener._apply_header({"height": 100, "hex": competing_100}) - - assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched - assert "competing header" in caplog.text - - async def test_apply_header_treats_the_same_header_re_announced_as_a_no_op(session_factory): # B-64 listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) header_100 = _mine_header("00" * 32) @@ -1115,3 +1095,162 @@ async def test_a_header_easier_than_the_pow_limit_ends_the_session(session_facto with pytest.raises(HeaderValidationError): await fresh._apply_header({"height": 100, "hex": chain[100]}, client) assert (listener.tip_height, fresh.tip_height) == (100, 0) + + +# --- B-85 (ELC-09, ELC-24, AUD-38): reorganisations are recorded, deep ones pause ------- + + +async def _reorg_events(session_factory) -> list[dict]: + import json + + async with session_factory() as session: + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "chain_reorg_detected"))).all() + return [json.loads(row.payload_json) for row in rows] + + +async def _config(session_factory) -> RoundConfig: + from app.rounds.config import get_round_config + + async with session_factory() as session: + return await get_round_config(session) + + +def _fork_at(chain_a: dict[int, str], fork_height: int, length: int, salt: int = 1) -> dict[int, str]: + """The server's chain: ours up to `fork_height`, then `length` blocks of its own.""" + branch = _mine_chain(fork_height + 1, length, header_hex_to_block_hash(chain_a[fork_height]), salt=salt) + return {**{h: v for h, v in chain_a.items() if h <= fork_height}, **branch} + + +async def test_a_one_block_reorg_is_followed_and_only_audited(session_factory): + """B-85, ELC-24: the tip 100 turns orphan and the server announces 101 on a branch from + 99 — depth 1. The listener follows it and writes `chain_reorg_detected`; nothing is + paused, since a one-block reorganisation is ordinary.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + chain_b = _fork_at(chain_a, 99, 2) + client.chain = chain_b + + await listener._apply_header({"height": 101, "hex": chain_b[101]}, client) + + assert (listener.tip_height, listener.tip_header_hex) == (101, chain_b[101]) + assert await _reorg_events(session_factory) == [ + { + "depth": 1, + "fork_height": 99, + "old_tip_height": 100, + "old_tip_hash": header_hex_to_block_hash(chain_a[100]), + "new_tip_height": 101, + "new_tip_hash": header_hex_to_block_hash(chain_b[101]), + } + ] + config = await _config(session_factory) + assert (config.paused, config.paused_reason) == (False, None) + + +async def test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow(session_factory): + """B-85, ELC-09: a header for the height we hold with a *different* hash used to be + ignored (B-64), leaving the tip on an orphan. It is now followed like any fork.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + chain_b = _fork_at(chain_a, 99, 1) + assert chain_b[100] != chain_a[100] + client.chain = chain_b + + await listener._apply_header({"height": 100, "hex": chain_b[100]}, client) + + assert (listener.tip_height, listener.tip_header_hex) == (100, chain_b[100]) + [event] = await _reorg_events(session_factory) + assert (event["depth"], event["fork_height"]) == (1, 99) + + +async def test_a_competing_header_without_work_or_chain_is_not_followed(session_factory): + """ELC-09 / ELC-23: the competing header has to prove itself — a forged one that does + not join the server's own chain ends the session and changes nothing.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + stranger = _mine_chain(100, 1, "ee" * 32, salt=5)[100] + client.chain = {**chain_a, 100: stranger} + + with pytest.raises(HeaderValidationError): + await listener._apply_header({"height": 100, "hex": stranger}, client) + assert listener.tip_header_hex == chain_a[100] + assert await _reorg_events(session_factory) == [] + + +async def test_a_reorg_of_depth_two_pauses_the_lottery_with_reason_reorg(session_factory): + """B-85, ELC-24: two of our blocks replaced (fork at 98, tip 100 -> 101) pauses the + lottery with `paused_reason = 'reorg'`, and the `lottery_paused` row is written with + the event. The round in progress is untouched; only the next one waits for the + operator.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + chain_b = _fork_at(chain_a, 98, 3) # 99', 100', 101' + client.chain = chain_b + + await listener._apply_header({"height": 101, "hex": chain_b[101]}, client) + + assert listener.tip_height == 101 + [event] = await _reorg_events(session_factory) + assert (event["depth"], event["fork_height"]) == (2, 98) + config = await _config(session_factory) + assert (config.paused, config.paused_reason) == (True, "reorg") + async with session_factory() as session: + reasons = [ + row.payload_json + for row in (await session.scalars(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).all() + ] + assert reasons == ['{"reason": "reorg"}'] + + +async def test_a_deeper_reorg_overrides_an_earlier_operator_pause_reason(session_factory): + """DAT-69: an automatic reason is written even over a pause that is already active.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + async with session_factory() as session: + from app.rounds.config import get_round_config + + config = await get_round_config(session) + config.paused, config.paused_reason = True, "operator" + await session.commit() + client.chain = _fork_at(chain_a, 96, 5) + + await listener._apply_header({"height": 101, "hex": client.chain[101]}, client) + + config = await _config(session_factory) + assert (config.paused, config.paused_reason) == (True, "reorg") + [event] = await _reorg_events(session_factory) + assert event["depth"] == 4 + + +async def test_a_fork_beyond_the_window_writes_no_event_and_pauses_nothing(session_factory): + """AUD-38: a fork deeper than 10 headers is not followed and writes no event — the + session ends instead.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + other = _mine_chain(91, 11, "dd" * 32, salt=2) + client.chain = other + with pytest.raises(HeaderValidationError): + await listener._apply_header({"height": 101, "hex": other[101]}, client) + assert await _reorg_events(session_factory) == [] + assert (await _config(session_factory)).paused is False + + +async def test_the_window_does_not_move_when_the_reorg_cannot_be_recorded(session_factory): + """A reorg is never followed without a trace: if the audit write fails the session + ends, and the next server announces the fork against an unchanged window.""" + chain_a = _mine_chain(91, 10) + listener, client = await _listener_at(session_factory, chain_a, 100) + client.chain = _fork_at(chain_a, 98, 3) + + def broken_session_factory(): + raise RuntimeError("database is locked") + + listener._session_factory = broken_session_factory + with pytest.raises(RuntimeError): + await listener._apply_header({"height": 101, "hex": client.chain[101]}, client) + assert (listener.tip_height, listener.tip_header_hex) == (100, chain_a[100]) + + listener._session_factory = session_factory # the database is back: same fork, now recorded + await listener._apply_header({"height": 101, "hex": client.chain[101]}, client) + assert listener.tip_height == 101 + assert len(await _reorg_events(session_factory)) == 1