Never let a draw be seeded by a block that predates the close (B-63)

ElectrumListener._run_once assigned self.client before subscribe_headers()
returned, so there was a window — one round-trip wide, at process start — where
the connection looked alive while tip_height was still its initial 0.
"client is not None" is what every consumer reads as "the chain is reachable",
RoundScheduler._tick included, and a round closing inside that window recorded
tip_at_close = 0. The very first header we then learned about — the current tip,
a block mined *before* the round closed, whose hash was already public while
bets were still open — satisfied tip_height > tip_at_close and became the draw's
entropy. The draw's whole guarantee is that its seed did not exist yet when
betting stopped.

Two changes, defending different things:

- The client is published only once the first header has been applied, so
  "client is not None" now means "reachable *and* we know where the chain is".
  During the window consumers see no connection, which is honest: a bet gets the
  same 503 it already gets while disconnected, and the background tasks skip a
  cycle as they already do.

- _wait_for_next_block treats a baseline of 0 as *unknown*, not as height zero:
  it adopts the first height it learns as the baseline, waits for a block
  strictly after it, and records draw_baseline_tip_unknown so the extra block of
  waiting is explainable from /admin. Unreachable via the listener now, but it is
  the local statement of what the draw requires, and nothing else in that
  function would notice if the invariant stopped holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 10:01:21 +02:00
co-authored by Claude Opus 5
parent 8dd913ec59
commit 8a0ebecfcc
6 changed files with 160 additions and 19 deletions
+50
View File
@@ -601,6 +601,56 @@ async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01
await asyncio.wait_for(_poll(), timeout=timeout)
async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_factory): # B-63
"""`self.client is not None` is what every consumer reads as "the chain is
reachable" — RoundScheduler._tick included, which then takes tip_height as the
baseline a draw must find a *later* block than. Publishing the client before the
first header left a window where the connection looked alive at tip_height 0, so a
round closing inside it would have seeded its draw from a block mined before the
close, whose hash was already public while bets were open."""
header_hex = _mine_header("00" * 32)
client = _FakeConnectClient({"height": 100, "hex": header_hex})
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
seen_while_subscribing: list[tuple[object, int]] = []
original_subscribe_headers = client.subscribe_headers
async def observing_subscribe_headers():
# Exactly the window that used to be exposed: connected, but no header yet.
seen_while_subscribing.append((listener.client, listener.tip_height))
return await original_subscribe_headers()
client.subscribe_headers = observing_subscribe_headers
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
try:
await _wait_until(lambda: listener.client is not None)
# Whenever the client is visible, the tip is already known — never 0.
assert listener.tip_height == 100
assert listener.tip_header_hex == header_hex
assert seen_while_subscribing == [(None, 0)]
finally:
await client.close()
await run_once_task
async def test_run_once_leaves_no_client_published_when_the_first_header_is_rejected(
session_factory,
): # B-63
"""A fabricated first header ends the session (B-28). The client must never
become visible on the way out either, or consumers would briefly see a
connection whose tip was never established."""
client = _FakeConnectClient({"height": 100, "hex": "00" * 80}) # fails its own target
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
with pytest.raises(HeaderValidationError):
await listener._run_once(_ENDPOINTS[0])
assert listener.client is None
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory):
"""The core B-31 fix: before this, _subscribe_all_users ran to completion
*before* the header-consuming task even started, so a reconnect with many
+57
View File
@@ -446,6 +446,63 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
assert events == ["draw_header_corroboration_failed"]
# --- B-63: an unknown tip at closing time must not become the draw's seed --------
class LateTipListener:
"""A listener that doesn't know the tip yet and learns it only once asked —
the state the old code could observe while `client` already looked alive."""
def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]):
self.tip_height = 0
self.tip_header_hex = None
self._learns = learns
self._then_advances_to = then_advances_to
self.corroboration_calls: list[int] = []
def learn_tip(self) -> None:
self.tip_height, self.tip_header_hex = self._learns
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
self.corroboration_calls.append(height)
return True
async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block(
session_factory, monkeypatch
): # B-63
"""A tip_at_close of 0 means the tip was *unknown* when the round closed, not
that the chain was at height zero. The first header we then learn describes a
block that may well predate the close — whose hash was public while bets were
still open — so it must become the baseline, never the seed: the draw waits for a
block strictly after it."""
import app.rounds.scheduler as scheduler_module
listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb"))
scheduler = RoundScheduler(session_factory, listener)
async def fake_sleep(_seconds):
# First sleep: the tip becomes known (height 500, the pre-close block).
# Second: a genuinely new block arrives on top of it.
if listener.tip_height == 0:
listener.learn_tip()
else:
listener.tip_height, listener.tip_header_hex = listener._then_advances_to
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
height, _block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc)
)
assert height == 501 # the block *after* the one we first learned about
assert listener.corroboration_calls == [501] # 500 was never even a candidate
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_baseline_tip_unknown"] # explainable from /admin
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------