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
+11 -1
View File
@@ -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")
+40
View File
@@ -224,9 +224,22 @@ class RoundScheduler:
draw_stalled audit entry is written (and re-written every threshold
interval for as long as the stall continues) so the wait shows up next
to the draw_header_corroboration_failed entries above.
B-63: `tip_at_close` of 0 means the tip was *unknown* when the round closed,
not that the chain was at height zero — and "the first block we hear about"
is then not necessarily a block mined after the close. Rather than seed the
draw from a hash that may already have been public while bets were open, the
first height we do learn becomes the baseline and this waits for a block
strictly after it. Since the Electrum listener now only publishes its client
once a header has been applied, and _tick won't run without one, this should
be unreachable — it stays as the local statement of what the draw actually
requires, since nothing else in this function would notice if that stopped
holding.
"""
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
if tip_at_close <= 0:
tip_at_close = await self._adopt_baseline_tip(round_id)
while True:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
now = datetime.now(timezone.utc)
@@ -274,6 +287,33 @@ class RoundScheduler:
await session.commit()
tip_at_close = height
async def _adopt_baseline_tip(self, round_id: int) -> int:
"""B-63: the height the draw must find a *later* block than, for the case
where the tip wasn't known at closing time. Waits for a header to arrive and
takes that height as the baseline — the block it describes may predate the
close, which is exactly why it is used as the floor rather than as the seed —
and records why, since a draw that waits one extra block should be explainable
from /admin rather than looking like a stall.
"""
while not self._listener.tip_header_hex or self._listener.tip_height <= 0:
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
logger.warning(
"round %s: chain tip was unknown at closing time; using height %s as the draw baseline "
"and waiting for a further block",
round_id,
height,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_baseline_tip_unknown",
{"baseline_height": height},
round_id=round_id,
)
await session.commit()
return height
async def _retry_payout_if_due(self, round_id: int) -> None:
"""B-26: whether a "paying_out" round is due for another payout attempt.