diff --git a/BUGS.md b/BUGS.md index 8f47162..429d36b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,10 +1,11 @@ # Known bugs A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high, -7 medium, 8 low), listed below as B-25 … B-49. B-25, B-26 and B-27 are fixed (see "Previously -fixed" below); the other 22 are open. The 139-test suite was green at the time of the audit, so -none of these were caught by existing coverage — every fix lands with a regression test (the -three fixes so far brought the suite from 139 to 151). +7 medium, 8 low), listed below as B-29 … B-49. B-25 through B-28 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 21 are High/Medium/Low. +The 139-test suite was green at the time of the audit, so none of these were caught by existing +coverage — every fix lands with a regression test (the four fixes so far brought the suite from +139 to 165). The recurring pattern across the open findings is worth stating once: the code is rigorous about the failure modes that have actually been hit, and silent about the ones that have not. @@ -18,31 +19,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD --- -## Critical - -### B-28 — A hostile Electrum server (or a MITM) can choose the winner - -`electrum/listener.py:167-186` accepts any header whose `height >= tip_height`: no -proof-of-work check, no linkage to the previous block hash. That header is the **sole source -of entropy for the draw** (`scheduler.py:129`). - -In parallel, `electrum/client.py:104-106` sets `check_hostname = False` and -`verify_mode = CERT_NONE`. The comment justifies this with "the protocol's trust model is -server consensus" — but there is no consensus here: one server at a time, rotated over a list -of arbitrary third parties. So a hostile server, or anyone able to MITM a connection that -validates no certificate, can fabricate a header and thereby decide who wins every round. - -**Proposed fix, in order of value.** (1) Validate headers before accepting them: check the -PoW against the claimed target and that `prev_block` matches the current tip; reject anything -that fails. (2) Do not trust one server for the draw — fetch the header for -`draw_block_height` from *several* endpoints in the rotation and require agreement before -using it as the seed. (3) Pin certificates (or verify hostnames) for the configured servers -rather than disabling verification wholesale. Longer term this is the argument for replacing -the v1 draw algorithm — CLAUDE.md already calls it a replaceable component — with a scheme -that does not depend on a single unauthenticated data source. - ---- - ## High ### B-29 — `detect_external_spends` is irreversible and trusts a single response @@ -321,17 +297,13 @@ already does. ## Previously fixed -B-25 (the payout had no two-phase write, unlike bets and withdrawals — a crash or rejected -broadcast could leave money on-chain with no DB record, or leave the round wedged with no way -to retry safely), B-26 (a transient failure or a process restart at payout time wedged the -round in `paying_out` forever, with most failure paths logging nothing) and B-27 (every RBF -bump reset the reconciler's own abandon clock, so a repeatedly-bumped-but-never-mined -transaction was never abandoned) are fixed as of 2026-07-27. Together B-25 and B-26 make -`paying_out` fully recoverable — a payout retry is now safe (persisted before broadcast, -guarded against double-spend) and automatic (the scheduler retries a stuck round on its own, -throttled, with every failure audit-logged with a reason, including across a process restart). -See git history (commits `f13f685`, `50a43ae`, `933760e`) for the fix-by-fix breakdown; the -regression suite grew from 139 to 151 tests over the three. +- **B-25** — the payout had no two-phase write, unlike bets and withdrawals +- **B-26** — a payout failure or a process restart could wedge a round in `paying_out` forever +- **B-27** — an RBF bump reset the reconciler's own abandon clock, so a repeatedly-bumped tx was never abandoned +- **B-28** — a hostile Electrum server (or a MITM) could single-handedly pick the round's winner + +See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the +B-28 fix). Suite grew from 139 to 165 tests over the four. A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical, diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 42c7c43..327462b 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -9,6 +9,12 @@ from app.db.models import User from app.deposits.service import credit_confirmed_utxos, detect_external_spends from app.electrum.client import ElectrumClient, ElectrumEndpoint from app.electrum.scripthash import address_to_scripthash +from app.rounds.draw import ( + HeaderValidationError, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) from app.rounds.events import broadcaster logger = logging.getLogger(__name__) @@ -18,6 +24,12 @@ logger = logging.getLogger(__name__) # difference between noticing the drop in a minute and never noticing it at all. _PING_INTERVAL_SECONDS = 60 +# B-28: how long to wait for any *one* other server's answer when corroborating the +# draw's block header. Shorter than the standard request timeout since this is a +# supplementary check across several servers at once — a single slow fallback +# shouldn't hold up the others. +_CORROBORATION_TIMEOUT_SECONDS = 10 + class ElectrumListener: """Long-lived background task: keeps one Electrum connection open, subscribes @@ -173,8 +185,19 @@ class ElectrumListener: the draw's wait. height and hex are applied together or not at all — applying a losing header's hex would leave tip_height and tip_header_hex describing different blocks, and that hex is the draw's entropy source. + + Two validation checks guard against a hostile or MITM'd server simply + fabricating a header (B-28), since that header is the draw's sole source of + entropy: it must satisfy the difficulty target it claims for itself, and — + when it's a direct single-block advance from our own current tip, the only + case we can check without a full header chain — it must chain from that + tip's hash. Either failure raises HeaderValidationError rather than + silently ignoring the header, which (via _consume_headers/_run_once) ends + this session the same way a dropped connection would, so run() rotates to + the next configured server instead of continuing to trust this one. """ height = header["height"] + header_hex = header.get("hex") if height < self.tip_height: logger.warning( "ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)", @@ -182,8 +205,73 @@ class ElectrumListener: self.tip_height, ) return + + if header_hex: + if not header_meets_its_own_target(header_hex): + raise HeaderValidationError( + f"header at height {height} does not satisfy its own claimed difficulty target" + ) + if ( + self.tip_header_hex + and height == self.tip_height + 1 + and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex) + ): + raise HeaderValidationError( + f"header at height {height} does not chain from the current tip (height {self.tip_height})" + ) + self.tip_height = height - self.tip_header_hex = header.get("hex") + self.tip_header_hex = header_hex + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + """B-28: independently ask every *other* configured server for the header + at `height` and require a strict majority of the ones that actually answer + to agree with `expected_hash` — the hash our own active connection + reported — before the draw (rounds/scheduler.py:_wait_for_next_block) treats + it as trustworthy entropy. Without this, a single hostile server (or a MITM + on the one active connection) can single-handedly decide who wins every + round; this raises the bar to controlling a majority of the configured + servers. + + Returns True if there are no other servers configured at all — a + single-endpoint deployment has nothing to corroborate against, and accepted + that risk when ELECTRUM_FALLBACK_SERVERS was left empty (see CLAUDE.md). + Also returns False (never silently "passes") if none of the other servers + could be reached at all, since an unreachable network answers nothing about + whether the header is genuine. + """ + others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint] + if not others: + return True + + async def _ask(endpoint: ElectrumEndpoint) -> str | None: + client = self._client_factory(endpoint) + try: + await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS) + result = await asyncio.wait_for( + client.request("blockchain.block.header", [height]), + timeout=_CORROBORATION_TIMEOUT_SECONDS, + ) + if not isinstance(result, str): + return None + return header_hex_to_block_hash(result) + except Exception: + return None + finally: + await client.close() + + results = await asyncio.gather(*(_ask(endpoint) for endpoint in others)) + responded = [block_hash for block_hash in results if block_hash is not None] + if not responded: + logger.warning( + "could not corroborate block %s header with any of %s other configured server(s)", + height, + len(others), + ) + return False + + agreements = sum(1 for block_hash in responded if block_hash == expected_hash) + return agreements * 2 > len(responded) async def _consume_headers(self, queue: asyncio.Queue) -> None: while True: diff --git a/app/rounds/draw.py b/app/rounds/draw.py index 9e26ffc..35107f8 100644 --- a/app/rounds/draw.py +++ b/app/rounds/draw.py @@ -1,5 +1,22 @@ import hashlib +# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) + +# merkle_root(32) + timestamp(4) + bits(4) + nonce(4). +_HEADER_LENGTH_BYTES = 80 +_PREV_BLOCK_OFFSET = 4 +_PREV_BLOCK_LENGTH = 32 +_BITS_OFFSET = 72 +_BITS_LENGTH = 4 + + +class HeaderValidationError(Exception): + """Raised by ElectrumListener._apply_header (B-28) when a header either doesn't + satisfy the difficulty target it claims for itself, or doesn't chain from the + previously accepted tip. Letting this propagate out of the header-consuming + task ends the current Electrum session the same way a dropped connection would + (see ElectrumListener._run_once), so the listener rotates to the next + configured server instead of trusting a header a server just forged.""" + def header_hex_to_block_hash(header_hex: str) -> str: """Block hash from a raw Electrum header: sha256d, byte-reversed, hex. @@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str: return digest[::-1].hex() +def header_prev_hash(header_hex: str) -> str: + """The header's `prev_block` field, byte-reversed to the same conventional + (display) order as header_hex_to_block_hash's return value, so the two can be + compared directly to check that one header actually chains from another.""" + header_bytes = bytes.fromhex(header_hex) + prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH] + return prev[::-1].hex() + + +def _target_from_bits(bits: int) -> int: + """Decompress Bitcoin-style compact `nBits` difficulty encoding into the full + 256-bit target a valid header's hash must be less than or equal to.""" + exponent = bits >> 24 + mantissa = bits & 0xFFFFFF + if exponent <= 3: + return mantissa >> (8 * (3 - exponent)) + return mantissa << (8 * (exponent - 3)) + + +def header_meets_its_own_target(header_hex: str) -> bool: + """Whether this header's hash satisfies the difficulty target *it claims for + itself* (the `bits` field). Rejects a header that was never actually mined — + e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28), + since satisfying a self-chosen target still requires real proof-of-work. + + This does NOT — and, short of downloading and validating the full header + chain's difficulty-retarget history, cannot — catch a header honestly mined at + a real but implausibly low self-chosen difficulty: a server could still declare + an easy target and grind it out with modest hardware. That residual risk is why + the draw additionally requires the winning block's header to be corroborated by + the *other* configured servers before using it as the seed (see + ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block) + rather than relying on this check alone. + """ + header_bytes = bytes.fromhex(header_hex) + if len(header_bytes) != _HEADER_LENGTH_BYTES: + return False + bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little") + target = _target_from_bits(bits) + if target <= 0: + return False + digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest() + # The hash as the integer comparable against `target`: this is the same digest + # header_hex_to_block_hash reverses into the conventional display hex, so + # reading it byte-reversed as a big-endian int is equivalent to reading the + # original digest bytes as little-endian — both give the same integer. + hash_int = int.from_bytes(digest, "little") + return hash_int <= target + + def draw_winner(participants: list[str], block_hash_hex: str) -> str: """v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer, index = seed mod participant_count, winner = participants[index]. Anyone can diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 1159c6b..e71fdb6 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -142,7 +142,7 @@ class RoundScheduler: broadcaster.publish() tip_at_close = self._listener.tip_height - block_height, block_hash = await self._wait_for_next_block(tip_at_close) + block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close) winner_address = draw_winner(addresses, block_hash) async with self._session_factory() as session: @@ -172,10 +172,38 @@ class RoundScheduler: logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount) await self._trigger_payout(round_id) - async def _wait_for_next_block(self, tip_at_close: int) -> tuple[int, str]: - while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: - await asyncio.sleep(_TICK_INTERVAL_SECONDS) - return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) + async def _wait_for_next_block(self, round_id: int, tip_at_close: int) -> tuple[int, str]: + """Waits for a block after tip_at_close and, before handing it back as the + draw's entropy source, requires it to be corroborated by the other + configured Electrum servers (B-28) — our own active connection is exactly + the thing a hostile server or a MITM would control, so its header alone is + not enough to seed a payout. A candidate that fails corroboration is never + used: this keeps waiting for a further block and tries corroborating that + one instead, logging why every time so a stuck draw is visible in + /admin's audit log rather than a silent, unexplained wait.""" + while True: + while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: + await asyncio.sleep(_TICK_INTERVAL_SECONDS) + height = self._listener.tip_height + block_hash = header_hex_to_block_hash(self._listener.tip_header_hex) + if await self._listener.corroborate_header(height, block_hash): + return height, block_hash + logger.error( + "round %s: block %s header %s could not be corroborated by other Electrum servers; " + "waiting for a further block", + round_id, + height, + block_hash, + ) + async with self._session_factory() as session: + await write_audit_log( + session, + "draw_header_corroboration_failed", + {"height": height, "reported_hash": block_hash}, + round_id=round_id, + ) + await session.commit() + tip_at_close = 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_draw.py b/tests/unit/test_draw.py index 4f1bea6..c2a63c1 100644 --- a/tests/unit/test_draw.py +++ b/tests/unit/test_draw.py @@ -1,19 +1,50 @@ import pytest -from app.rounds.draw import draw_winner, header_hex_to_block_hash +from app.rounds.draw import ( + draw_winner, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) + +# Real PLM mainnet block 477486: header from blockchain.block.header, hash +# cross-checked against the blockhash reported by blockchain.transaction.get for a +# tx confirmed in that block. +_REAL_HEADER_HEX = ( + "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" + "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" + "e6a47a61b1ad8012582" +) def test_header_hex_to_block_hash_matches_known_mainnet_block(): - # Real PLM mainnet block 477486: header from blockchain.block.header, hash - # cross-checked against the blockhash reported by blockchain.transaction.get - # for a tx confirmed in that block. - header_hex = ( - "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" - "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" - "e6a47a61b1ad8012582" - ) known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86" - assert header_hex_to_block_hash(header_hex) == known_block_hash + assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash + + +def test_header_meets_its_own_target_accepts_a_real_mined_header(): + """B-28: a genuinely mined mainnet header must pass its own self-consistency + check — this isn't just a synthetic-header property.""" + assert header_meets_its_own_target(_REAL_HEADER_HEX) is True + + +def test_header_meets_its_own_target_rejects_a_tampered_header(): + """Flipping a single nonce bit changes the hash completely (avalanche effect) + without changing the claimed difficulty, so a tampered-but-otherwise-real + header should almost certainly fail — this is what would catch a + hostile/MITM'd server replaying a real header with a doctored field.""" + tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX)) + tampered[-1] ^= 0xFF # flip the last byte of the nonce + assert header_meets_its_own_target(tampered.hex()) is False + + +def test_header_meets_its_own_target_rejects_wrong_length(): + assert header_meets_its_own_target("aa" * 10) is False + + +def test_header_prev_hash_matches_the_known_previous_block(): + # Block 477486's predecessor, 477485 — independently known from the same chain. + assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb" def test_draw_winner_is_deterministic_and_within_range(): diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index d0a63ca..2e18730 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -1,5 +1,6 @@ """Listener-level behaviour: server rotation on failure (the fallback-servers -feature), and the chain-tip monotonicity guard (B-19). +feature), the chain-tip monotonicity guard (B-19), header validation and +multi-server corroboration (B-28). The reconnect loop itself (B-01) is covered from the client side in test_electrum_client.py — what's asserted here is that the listener *acts* on a @@ -7,6 +8,7 @@ dead connection by moving to the next server instead of retrying the same one. """ import asyncio +import struct import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -14,6 +16,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.electrum.client import ElectrumEndpoint from app.electrum.listener import ElectrumListener +from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target @pytest.fixture @@ -31,6 +34,34 @@ _ENDPOINTS = [ ElectrumEndpoint("third.example", 50001, False), ] +# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining +# a real, self-consistent test header takes a handful of nonce attempts rather than +# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty — +# irrelevant here, since header_meets_its_own_target only checks self-consistency. +_EASY_BITS = 0x207FFFFF + + +def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str: + return ( + struct.pack(" str: + """A real header that satisfies its own claimed target — good enough to + exercise header_meets_its_own_target/_apply_header for real, without needing + genuine PLM-mainnet-grade hashpower.""" + for nonce in range(100_000): + header_hex = _build_header(prev_hash_hex, nonce, bits=bits) + if header_meets_its_own_target(header_hex): + return header_hex + raise RuntimeError("failed to mine a test header within the attempt budget") + async def test_rotates_to_the_next_server_after_a_failed_session(session_factory): """One unreachable server should cost a single attempt, not an outage: every @@ -132,11 +163,144 @@ def test_tip_never_moves_backwards(session_factory): a stale one.""" listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) - listener._apply_header({"height": 100, "hex": "aa"}) - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 99, "hex": "bb"}) # reorg, or a server switch - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + # A lower height is ignored purely on height, before any header validation even + # runs — reorg or server switch, not a real advance. + listener._apply_header({"height": 99, "hex": "bb"}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 101, "hex": "cc"}) - assert (listener.tip_height, listener.tip_header_hex) == (101, "cc") + header_101 = _mine_header(header_hex_to_block_hash(header_100)) + listener._apply_header({"height": 101, "hex": header_101}) + assert (listener.tip_height, listener.tip_header_hex) == (101, header_101) + + +# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the +# draw's entropy — header self-consistency/linkage checks, and multi-server +# corroboration for the block the draw actually uses. --------------------------- + + +def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + # Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by + # chance is astronomically unlikely, so this header is self-inconsistent. + forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 100, "hex": forged}) + assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched + + +def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + # A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated + # chain — well-formed and self-consistently mined, but not actually built on + # top of our current tip. + disconnected = _mine_header("ff" * 32) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 101, "hex": disconnected}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched + + +def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory): + """A reconnect (or the very first header of a session) hands us whatever the + server's current tip is — which is legitimately not a single-block advance + from whatever we last saw. There's no full header chain to check linkage + against in that case, so only self-consistency is enforced.""" + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50 + listener._apply_header({"height": 150, "hex": header_150}) # must not raise + + assert (listener.tip_height, listener.tip_header_hex) == (150, header_150) + + +async def _endpoint_client_factory(responses: dict[str, object]): + """Builds a client_factory whose fake clients answer blockchain.block.header + per-endpoint according to `responses`: a header hex string to agree/disagree + with, `None` to simulate an unreachable server, or an Exception instance to + simulate a request failure.""" + + class _FakeClient: + def __init__(self, answer): + self._answer = answer + self.closed = False + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def request(self, method, params): + assert method == "blockchain.block.header" + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + self.closed = True + + def factory(endpoint): + return _FakeClient(responses[endpoint.host]) + + return factory + + +async def test_corroborate_header_true_with_no_other_servers_configured(session_factory): + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_header(100, "deadbeef") is True + + +async def test_corroborate_header_true_when_others_agree(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory): + """The active connection is exactly what a hostile server or a MITM would + control — corroborating against it too would defeat the point.""" + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + # first.example (the active endpoint) would raise if ever queried. + factory = await _endpoint_client_factory( + {"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert listener.current_endpoint.host == "first.example" + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_false_when_majority_disagrees(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + disagreeing_hex = _mine_header("11" * 32) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is False + + +async def test_corroborate_header_false_when_nobody_responds(session_factory): + factory = await _endpoint_client_factory( + {"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, "deadbeef") is False diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 7b323d2..1d3dc97 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -353,3 +353,53 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_ async with payout_session_factory() as session: pending = (await session.scalars(select(PendingTransaction))).one() assert pending.status == "pending" + + +# --- B-28: the draw must not seed itself from an uncorroborated header ----------- + + +class CorroboratingListener: + """A fake listener whose tip advances the moment a corroboration attempt + fails, simulating a further block arriving — lets tests drive + _wait_for_next_block's retry loop deterministically without real sleeps.""" + + def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None): + self.tip_height, self.tip_header_hex = next(iter(responses)), "aa" + self._responses = dict(responses) + self._advance_to = advance_to or {} + self.corroboration_calls: list[int] = [] + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + self.corroboration_calls.append(height) + result = self._responses[height] + if not result and height in self._advance_to: + self.tip_height, self.tip_header_hex = self._advance_to[height] + return result + + +async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory): + listener = CorroboratingListener(responses={101: True}) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + + assert height == 101 + assert listener.corroboration_calls == [101] + + +async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory): + """B-28: an uncorroborated header must never be used — the wait keeps going + until a later block's header *is* corroborated, logging why each time.""" + listener = CorroboratingListener( + responses={101: False, 102: True}, advance_to={101: (102, "bb")} + ) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + + assert height == 102 + assert listener.corroboration_calls == [101, 102] + + async with session_factory() as session: + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert events == ["draw_header_corroboration_failed"]