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:
@@ -40,22 +40,6 @@ remains the last prerequisite for running unattended.
|
||||
|
||||
## Medium — correctness and robustness
|
||||
|
||||
### B-63 — `tip_height == 0` window right after connecting can seed a draw from a pre-close block
|
||||
|
||||
`app/electrum/listener.py:160-167`, `app/rounds/scheduler.py:153`.
|
||||
|
||||
`_run_once` assigns `self.client` *before* `subscribe_headers()` returns, so there
|
||||
is a window in which the client looks alive while `tip_height` is still 0 and
|
||||
`tip_header_hex` is `None`. A `_close_and_draw` entering that window records
|
||||
`tip_at_close = 0`, and the first header applied — the current tip, a block mined
|
||||
*before* the round closed — satisfies `tip_height > tip_at_close` and becomes the
|
||||
draw's entropy. The draw must use a block that did not exist at close time; a block
|
||||
whose hash was already public before betting closed is not the guarantee the
|
||||
flowchart describes.
|
||||
|
||||
Fix: publish `self.client` only after the first header has been applied, or refuse
|
||||
to draw while `tip_header_hex is None` / `tip_height == 0`.
|
||||
|
||||
### B-64 — `_apply_header` accepts a same-height header without the chaining check
|
||||
|
||||
`app/electrum/listener.py:271-296`.
|
||||
|
||||
Reference in New Issue
Block a user