Never swap the tip's hash sideways, and never split it from its height (B-64)

_apply_header's linkage check only fires on a single-block advance, so a header
at the height we already held one for was applied on nothing but its own
self-consistency — and that check, as header_meets_its_own_target's own docstring
says, a server can satisfy with a self-declared easy target. So the one value the
draw is seeded from could be replaced under us at the current height, by a reorg
at the tip or by a single server disagreeing with the rest, with no check able to
speak to it. Separately, a header carrying no hex set tip_header_hex back to None
while advancing tip_height, leaving the two describing different blocks — the
exact pairing that function exists to keep.

Both are now refused without ending the session, unlike the fabrication cases
above them: neither is evidence of a hostile server, and rotating away would cost
us the one connection that also credits deposits and broadcasts transactions.

- A same-height header is ignored (logged when it actually differs). The hash
  committed to for a height is not swapped under us; if ours turns out to be the
  orphan, corroborate_header already refuses to seed a draw from it and the draw
  waits for a further block.
- A hex-less header is ignored outright: nothing to validate, nothing to draw
  from. A server that only ever pushed heights now freezes the draw — visibly,
  via B-36's draw_stalled — instead of costing us the connection.

Because ignoring is not fatal, _run_once additionally refuses to publish the
client when the initial header leaves the tip still unknown, so this cannot
reopen B-63's window from the other side.

The two _run_once tests are bounded with asyncio.wait_for: without their guard
that call waits on session tasks nothing ends, and a regression must fail rather
than hang the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 10:29:37 +02:00
co-authored by Claude Opus 5
parent 8a0ebecfcc
commit c0314e2bf0
4 changed files with 124 additions and 30 deletions
+58 -13
View File
@@ -165,6 +165,15 @@ class ElectrumListener:
try:
header = await client.subscribe_headers()
self._apply_header(header)
if self.tip_header_hex is None:
# B-64: the header was unusable (no hex) and we have never had a tip,
# so publishing this client would hand every consumer a connection
# whose chain position is unknown — B-63 all over again. A server at
# or behind a tip we already know is fine and doesn't come through
# here: the point is only that *some* tip is established.
raise HeaderValidationError(
f"{endpoint} announced an unusable initial header ({header!r}) and no tip is known"
)
# B-63: published only now, never before the first header has been
# applied. `self.client is not None` is what every consumer treats as
# "the chain is reachable" — including RoundScheduler._tick, which then
@@ -260,7 +269,7 @@ class ElectrumListener:
await self.refresh_user(user_id, scripthash)
def _apply_header(self, header: dict) -> None:
"""Record a new chain tip, refusing to move backwards.
"""Record a new chain tip, refusing to move backwards or sideways.
Full reorg handling is out of scope for v1 by explicit design decision, but
the tip must never regress: `_wait_for_next_block` waits for
@@ -278,9 +287,29 @@ class ElectrumListener:
silently ignoring the header, which (via _consume_headers/_run_once) ends
this session the same way a dropped connection would, so run() rotates to
the next configured server instead of continuing to trust this one.
Two more headers are refused without ending the session, since neither is
evidence of a hostile server the way the above are (B-64): one carrying no
hex, and one at the height we already hold a header for.
"""
height = header["height"]
header_hex = header.get("hex")
if not header_hex:
# Nothing to validate and nothing to draw from — and applying the height
# alone would break exactly the pairing this function exists to keep:
# tip_height would describe a block tip_header_hex doesn't (and on a
# session's first header, would publish a client whose tip is unknown,
# which is B-63). Ignored rather than fatal: a server that only ever
# pushed heights would freeze the draw — visibly, via B-36's
# draw_stalled — instead of costing us the one connection that also
# credits deposits and broadcasts transactions. _run_once separately
# refuses to publish a client while the tip is still unknown.
logger.warning(
"ignoring Electrum header at height %s: no header hex to validate or to draw from", height
)
return
if height < self.tip_height:
logger.warning(
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
@@ -289,19 +318,35 @@ class ElectrumListener:
)
return
if header_hex:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} does not satisfy its own claimed difficulty target"
)
if (
self.tip_header_hex
and height == self.tip_height + 1
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
):
raise HeaderValidationError(
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
if height == self.tip_height and self.tip_header_hex:
# B-64: a second header for the height we already hold one for. Either the
# same block re-announced (nothing to do) or a competing one — a reorg at
# the tip, or a server swapping out the very hash a draw may be about to
# use. The linkage check above cannot speak to this case at all, since
# there is no height advance to check. Whichever it is, the hash committed
# to for a height is not replaced under us: if ours turns out to be the
# orphan, corroborate_header (B-28) refuses to seed a draw from it and the
# draw waits for a further block instead.
if header_hex != self.tip_header_hex:
logger.warning(
"ignoring a competing header at the current tip height %s "
"(reorg at the tip, or a server disagreeing with the rest)",
height,
)
return
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} does not satisfy its own claimed difficulty target"
)
if (
self.tip_header_hex
and height == self.tip_height + 1
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
):
raise HeaderValidationError(
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
)
self.tip_height = height
self.tip_header_hex = header_hex