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
-16
View File
@@ -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`.
+2 -2
View File
@@ -130,7 +130,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. **A non-null `client` means the tip is already known**: `_run_once` publishes it only after the first header has been applied (B-63), so `client is not None` can be read as "the chain is reachable *and* we know where it is" — `tip_height` is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
## Electrum connection
@@ -157,7 +157,7 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec
**DRAW** — configurable timer (default 600s):
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open``closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. The baseline the draw compares against (`tip_at_close`) must be a height we actually knew at closing time: a `0` there means *unknown*, not "the chain is at zero", so `_wait_for_next_block` adopts the first height it then learns as the baseline and waits for a block strictly after it (`draw_baseline_tip_unknown`, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain.
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
+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.
+50
View File
@@ -601,6 +601,56 @@ async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01
await asyncio.wait_for(_poll(), timeout=timeout)
async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_factory): # B-63
"""`self.client is not None` is what every consumer reads as "the chain is
reachable" — RoundScheduler._tick included, which then takes tip_height as the
baseline a draw must find a *later* block than. Publishing the client before the
first header left a window where the connection looked alive at tip_height 0, so a
round closing inside it would have seeded its draw from a block mined before the
close, whose hash was already public while bets were open."""
header_hex = _mine_header("00" * 32)
client = _FakeConnectClient({"height": 100, "hex": header_hex})
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
seen_while_subscribing: list[tuple[object, int]] = []
original_subscribe_headers = client.subscribe_headers
async def observing_subscribe_headers():
# Exactly the window that used to be exposed: connected, but no header yet.
seen_while_subscribing.append((listener.client, listener.tip_height))
return await original_subscribe_headers()
client.subscribe_headers = observing_subscribe_headers
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
try:
await _wait_until(lambda: listener.client is not None)
# Whenever the client is visible, the tip is already known — never 0.
assert listener.tip_height == 100
assert listener.tip_header_hex == header_hex
assert seen_while_subscribing == [(None, 0)]
finally:
await client.close()
await run_once_task
async def test_run_once_leaves_no_client_published_when_the_first_header_is_rejected(
session_factory,
): # B-63
"""A fabricated first header ends the session (B-28). The client must never
become visible on the way out either, or consumers would briefly see a
connection whose tip was never established."""
client = _FakeConnectClient({"height": 100, "hex": "00" * 80}) # fails its own target
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
with pytest.raises(HeaderValidationError):
await listener._run_once(_ENDPOINTS[0])
assert listener.client is None
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory):
"""The core B-31 fix: before this, _subscribe_all_users ran to completion
*before* the header-consuming task even started, so a reconnect with many
+57
View File
@@ -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 --------------