diff --git a/BUGS.md b/BUGS.md index 175ea7b..a7bef1a 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index 139e923..f49e0a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 189a1ab..0611cca 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -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") diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 1ce0469..e7b51b9 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -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. diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index d7823ac..a12a225 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -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 diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index b1a4d13..c94a170 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -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 --------------