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>