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:
@@ -160,12 +160,22 @@ class ElectrumListener:
|
||||
reset its backoff), False if it never got that far."""
|
||||
client = self._client_factory(endpoint)
|
||||
await client.connect()
|
||||
self.client = client
|
||||
logger.info("Electrum connected to %s", endpoint)
|
||||
|
||||
try:
|
||||
header = await client.subscribe_headers()
|
||||
self._apply_header(header)
|
||||
# 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
|
||||
# reads tip_height as the baseline a draw must find a *later* block than.
|
||||
# Assigning it before this round-trip left a window where the connection
|
||||
# looked alive while tip_height was still 0, so a round closing inside it
|
||||
# recorded a baseline of 0 and the very first header we learned — the
|
||||
# current tip, a block mined *before* the round closed, with a hash
|
||||
# already public while bets were still open — satisfied
|
||||
# `tip_height > tip_at_close` and seeded the draw.
|
||||
self.client = client
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
|
||||
Reference in New Issue
Block a user