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
-15
View File
@@ -40,21 +40,6 @@ remains the last prerequisite for running unattended.
## Medium — correctness and robustness
### B-64 — `_apply_header` accepts a same-height header without the chaining check
`app/electrum/listener.py:271-296`.
The chain check only runs for `height == self.tip_height + 1`. A header at exactly
the current tip height replaces `tip_header_hex` after passing only the
self-target check — which, as the docstring of `header_meets_its_own_target`
already notes, a server can satisfy with a self-declared easy target. Separately,
a header with no `hex` field sets `tip_header_hex = None`, discarding a tip we
otherwise accepted.
Fix: treat a same-height header as either ignorable or as a reorg signal rather
than silently replacing the entropy source, and don't clear `tip_header_hex` on a
hex-less notification.
### B-65 — `/rounds/current` counts unconfirmed participants; the draw and payout do not
`app/api/routes/rounds.py:131-143` vs `app/rounds/scheduler.py:126`.
+1 -1
View File
@@ -139,7 +139,7 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). 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`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. 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.
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. 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. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `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.
- **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. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. 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.
+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
+65 -1
View File
@@ -247,6 +247,52 @@ def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_facto
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
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)
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):
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
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)
listener._apply_header({"height": 100, "hex": header_100})
listener._apply_header({"height": 100, "hex": header_100}) # must not raise
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
def test_apply_header_ignores_one_carrying_no_hex(session_factory, caplog): # B-64
"""A hex-less header can be neither validated nor drawn from, and applying its
height alone used to *clear* the hex we already had — leaving tip_height and
tip_header_hex describing different blocks, which is the one thing this function
exists to prevent."""
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
with caplog.at_level(logging.WARNING):
listener._apply_header({"height": 101}) # height only, no hex
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # pair intact
assert "no header hex" in caplog.text
def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory):
"""A reconnect (or the very first header of a session) hands us whatever the
server's current tip is — which is legitimately not a single-block advance
@@ -634,6 +680,24 @@ async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_
await run_once_task
async def test_run_once_refuses_a_session_whose_initial_header_carries_no_hex(session_factory): # B-64
"""A hex-less header is ignored rather than fatal (a server that only pushes
heights must not cost us the connection that also credits deposits) — but on the
*first* header of a process there is no tip to fall back on, and publishing the
client anyway would hand consumers a connection whose chain position is unknown,
which is exactly what B-63 closed."""
client = _FakeConnectClient({"height": 100}) # no "hex"
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
# Bounded: without the guard _run_once goes on to wait on the session's tasks,
# which nothing in this test ever ends — a regression must fail, not hang.
with pytest.raises(HeaderValidationError):
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
assert listener.client is None
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
async def test_run_once_leaves_no_client_published_when_the_first_header_is_rejected(
session_factory,
): # B-63
@@ -645,7 +709,7 @@ async def test_run_once_leaves_no_client_published_when_the_first_header_is_reje
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
with pytest.raises(HeaderValidationError):
await listener._run_once(_ENDPOINTS[0])
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
assert listener.client is None
assert (listener.tip_height, listener.tip_header_hex) == (0, None)