Follow and record chain reorganisations, pausing on deep ones (B-85, ELC-09, ELC-22, ELC-23, ELC-24, AUD-38, DAT-69)

A reorganisation was neither recorded nor acted upon, and a competing header at the tip's
own height was ignored for as long as the tip stood, leaving the listener on an orphan.

- A header at the height we hold with a different hash is followed through the same
  fork-point search as any other reorg (ELC-09) instead of being ignored; it still has to
  bring valid work and a connected chain.
- ElectrumListener._record_reorg writes `chain_reorg_detected` (depth, fork height, old and
  new tip height and hash) before the window moves, so a failed write ends the session and
  the next server's announcement of the same fork is recorded then. Depth 1 is a
  transaction of its own; depth >= 2 also pauses the lottery with paused_reason 'reorg'
  and writes `lottery_paused` in the same transaction (begin_immediate first).
- Docs: ELC-09/22/23/24, AUD-03/11/38, DAT-30/69, OPS-30, RND-04, ADR-26/52 lose their
  B-85 "da implementare" lines.

Tests changed because the spec changed: test_apply_header_ignores_a_competing_header_at_
the_current_tip_height encoded the B-64 behaviour that ELC-09 now reverses; it is replaced
by test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow and the depth tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 15:56:21 +02:00
1 parent ca50ef62f3
commit 50b68d2957
16 files changed
+254 -111

No files matched your search

+42 -18
View File
@@ -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,