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:
@@ -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 --------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user