Require real proof of work in headers and link every advance to the tip (B-74, ELC-11, ELC-12, ELC-22, ELC-23)

A header only had to meet the target it declared for itself, so a fabricated one with
`bits = 0x207fffff` passed half the time, a jump of several blocks was accepted without
being linked to the tip (a fake header at a huge height locked `tip_height` and froze every
draw), and an ordinary one-block reorganisation closed the session on every server.

- draw.py: MAINNET_POW_LIMIT (00000000ffff...ffff); header_meets_its_own_target also
  refuses a claimed target easier than POW_LIMIT.
- ElectrumListener._apply_header is async and takes the active client. A single block must
  chain by prev_hash; a jump downloads the headers in between (blockchain.block.headers,
  at most 2016 per request, ElectrumClient.get_block_headers) and each must carry valid
  work and chain, otherwise HeaderValidationError ends the session.
- The listener keeps the last 10 headers (`_window`), built from the process's first header
  and its 9 predecessors (which must chain into it). A header that does not join the tip is
  walked back to its fork point inside the window and the server's branch is followed
  instead of ending the session; a fork older than the window, or a branch that does not
  end at the announced header, still ends it. The audit row and the pause for deep reorgs
  are B-85; a competing header at the tip's own height is still ignored until then.
- _header_at also serves older heights from the window.

Tests changed because the spec changed: the mainnet powLimit cannot be mined in a test, so
tests/unit/conftest.py swaps in a regtest limit at import time (the new tests put the real
one back); the _apply_header tests are async; the two _run_once tests serve the first
header's 9 predecessors; test_apply_header_skips_linkage_check_across_a_height_gap is
replaced by the jump tests, since that behaviour is exactly what the finding removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 15:45:34 +02:00
1 parent a3c51719bd
commit ca50ef62f3
16 files changed
+494 -159

No files matched your search

+9
View File
@@ -23,3 +23,12 @@ def _throwaway_master_key(tmp_path, monkeypatch):
hd.generate_master_key()
yield
hd._account_key = None
# B-74: the real powLimit needs ~2^32 hashes per header, so tests mine headers at a trivial
# target (`0x207fffff`) and this swaps in a limit that allows it. Done at import, not in a
# fixture, because some test modules mine headers while they are being collected. The
# tests that exercise the limit itself put `MAINNET_POW_LIMIT` back with monkeypatch.
from app.rounds import draw as _draw # noqa: E402
_draw.POW_LIMIT = (1 << 256) - 1
+39
View File
@@ -61,3 +61,42 @@ def test_draw_winner_single_participant_always_wins():
def test_draw_winner_raises_on_empty_participants():
with pytest.raises(ValueError):
draw_winner([], "00" * 32)
# --- B-74 (ELC-11): the claimed target must not be easier than the powLimit ------------
def test_pow_limit_is_the_mainnet_constant():
"""B-74, ELC-11: PLM mainnet's `consensus.powLimit`, 00000000ffff…ffff."""
from app.rounds import draw
assert draw.MAINNET_POW_LIMIT == int("00000000" + "ff" * 28, 16)
assert draw.MAINNET_POW_LIMIT == (1 << 224) - 1
def test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target(monkeypatch):
"""B-74, ELC-11: a header whose hash satisfies the target *it declares* (bits =
0x207fffff, a target of about 2^255) used to pass — and whoever fabricates a header
picks its bits, so a random header passed with probability 1/2. With the mainnet
powLimit in force it is rejected however its hash falls."""
import struct
from app.rounds import draw
def build(nonce: int) -> str:
return (
struct.pack("<I", 1) + bytes(32) + bytes(32) + struct.pack("<II", 0, 0x207FFFFF) + struct.pack("<I", nonce)
).hex()
# Mined at the trivial target, so it satisfies its own claim ...
header = next(h for h in map(build, range(1000)) if header_meets_its_own_target(h)) # regtest limit (conftest)
# ... and is nonetheless refused once the real limit is back.
monkeypatch.setattr(draw, "POW_LIMIT", draw.MAINNET_POW_LIMIT)
assert header_meets_its_own_target(header) is False
def test_a_real_mainnet_header_passes_with_the_mainnet_pow_limit(monkeypatch):
from app.rounds import draw
monkeypatch.setattr(draw, "POW_LIMIT", draw.MAINNET_POW_LIMIT)
assert header_meets_its_own_target(_REAL_HEADER_HEX) is True
+223 -36
View File
@@ -46,12 +46,12 @@ _ENDPOINTS = [
_EASY_BITS = 0x207FFFFF
def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str:
def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS, timestamp: int = 0) -> str:
return (
struct.pack("<I", 1) # version
+ bytes.fromhex(prev_hash_hex)[::-1]
+ bytes.fromhex("00" * 32) # merkle_root, irrelevant to the checks under test
+ struct.pack("<I", 0) # timestamp
+ struct.pack("<I", timestamp)
+ struct.pack("<I", bits)
+ struct.pack("<I", nonce)
).hex()
@@ -194,7 +194,7 @@ async def test_address_for_new_user_retains_and_logs_a_failed_subscribe_task(ses
assert "could not subscribe" in caplog.text
def test_tip_never_moves_backwards(session_factory):
async def test_tip_never_moves_backwards(session_factory):
"""B-19: `self.tip_height = header["height"]` accepted a lower height, and
_wait_for_next_block waits for tip_height > tip_at_close — so a regression
silently added a block to the draw's wait. The hash must not move either: it's
@@ -203,16 +203,16 @@ def test_tip_never_moves_backwards(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
await listener._apply_header({"height": 100, "hex": header_100})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
# 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"})
await listener._apply_header({"height": 99, "hex": "bb"})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
header_101 = _mine_header(header_hex_to_block_hash(header_100))
listener._apply_header({"height": 101, "hex": header_101})
await listener._apply_header({"height": 101, "hex": header_101})
assert (listener.tip_height, listener.tip_header_hex) == (101, header_101)
@@ -221,21 +221,21 @@ def test_tip_never_moves_backwards(session_factory):
# corroboration for the block the draw actually uses. ---------------------------
def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory):
async 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})
await 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):
async 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})
await 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
@@ -243,11 +243,11 @@ def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_facto
disconnected = _mine_header("ff" * 32)
with pytest.raises(HeaderValidationError):
listener._apply_header({"height": 101, "hex": disconnected})
await listener._apply_header({"height": 101, "hex": disconnected})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(session_factory, caplog): # B-64
async def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(session_factory, caplog): # B-64
"""The linkage check only fires on a single-block advance, so a header at the
height we already hold one for used to be applied on nothing but its own
self-consistency — replacing the very hash a draw may be about to be seeded
@@ -256,56 +256,61 @@ def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(sessi
the orphan, corroborate_header refuses to draw from it anyway."""
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
await listener._apply_header({"height": 100, "hex": header_100})
competing_100 = _mine_header("11" * 32) # same height, well-formed, different block
assert competing_100 != header_100
with caplog.at_level(logging.WARNING):
listener._apply_header({"height": 100, "hex": competing_100})
await listener._apply_header({"height": 100, "hex": competing_100})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
assert "competing header" in caplog.text
def test_apply_header_treats_the_same_header_re_announced_as_a_no_op(session_factory): # B-64
async def test_apply_header_treats_the_same_header_re_announced_as_a_no_op(session_factory): # B-64
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
await listener._apply_header({"height": 100, "hex": header_100})
listener._apply_header({"height": 100, "hex": header_100}) # must not raise
await listener._apply_header({"height": 100, "hex": header_100}) # must not raise
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
def test_apply_header_ignores_one_carrying_no_hex(session_factory, caplog): # B-64
async def test_apply_header_ignores_one_carrying_no_hex(session_factory, caplog): # B-64
"""A hex-less header can be neither validated nor drawn from, and applying its
height alone used to *clear* the hex we already had — leaving tip_height and
tip_header_hex describing different blocks, which is the one thing this function
exists to prevent."""
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
await listener._apply_header({"height": 100, "hex": header_100})
with caplog.at_level(logging.WARNING):
listener._apply_header({"height": 101}) # height only, no hex
await listener._apply_header({"height": 101}) # height only, no hex
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # pair intact
assert "no header hex" in caplog.text
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})
def _serve_headers(chain: dict[int, str], start: int, count: int, limit: int = 2016) -> dict:
"""What `blockchain.block.headers` answers from `chain` (height -> header hex)."""
heights = [h for h in range(start, start + min(count, limit)) if h in chain]
return {"count": len(heights), "hex": "".join(chain[h] for h in heights), "max": limit}
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)
def _mine_chain(first_height: int, length: int, prev_hash: str = "00" * 32, *, salt: int = 0) -> dict[int, str]:
"""`length` linked, really-mined headers from `first_height`; `salt` makes a different
branch from the same parent (it changes the timestamp the headers are mined with)."""
chain: dict[int, str] = {}
for height in range(first_height, first_height + length):
for nonce in range(100_000):
header_hex = _build_header(prev_hash, nonce, timestamp=salt)
if header_meets_its_own_target(header_hex):
break
chain[height] = header_hex
prev_hash = header_hex_to_block_hash(header_hex)
return chain
async def _endpoint_client_factory(responses: dict[str, object]):
@@ -618,11 +623,15 @@ class _FakeConnectClient:
"""A minimally-real ElectrumClient double: enough of connect/subscribe/notify/
ping/wait_closed/close to drive ElectrumListener._run_once end-to-end."""
def __init__(self, header: dict):
def __init__(self, header: dict, chain: dict[int, str] | None = None):
self._header = header
self._chain = chain or {}
self._queues: dict[str, asyncio.Queue] = {}
self._closed = asyncio.Event()
async def get_block_headers(self, start_height: int, count: int) -> dict:
return _serve_headers(self._chain, start_height, count)
async def connect(self):
pass
@@ -657,8 +666,9 @@ async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_
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})
chain = _mine_chain(91, 10) # the first header's nine predecessors come with it (ELC-22)
header_hex = chain[100]
client = _FakeConnectClient({"height": 100, "hex": header_hex}, chain)
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
seen_while_subscribing: list[tuple[object, int]] = []
@@ -726,8 +736,9 @@ async def test_run_once_keeps_consuming_headers_while_resubscribing(session_fact
in flight."""
await _seed_users(session_factory, 3)
header_hex = _mine_header("00" * 32)
client = _FakeConnectClient({"height": 100, "hex": header_hex})
chain = _mine_chain(91, 10)
header_hex = chain[100]
client = _FakeConnectClient({"height": 100, "hex": header_hex}, chain)
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
subscribe_started = asyncio.Event()
@@ -928,3 +939,179 @@ async def test_the_lifespan_refuses_to_serve_without_a_quorum(monkeypatch):
with pytest.raises(ConfigError):
async with lifespan(FastAPI()):
pass
# --- B-74 (ELC-11, ELC-12, ELC-22, ELC-23): linked headers, a followed fork --------------
class _HeadersClient:
"""Serves `blockchain.block.headers` from a chain and records what was asked."""
def __init__(self, chain: dict[int, str], limit: int = 2016):
self.chain = chain
self.limit = limit
self.asked: list[tuple[int, int]] = []
async def get_block_headers(self, start: int, count: int) -> dict:
self.asked.append((start, count))
return _serve_headers(self.chain, start, count, self.limit)
async def _listener_at(session_factory, chain: dict[int, str], tip: int) -> tuple[ElectrumListener, _HeadersClient]:
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
client = _HeadersClient(chain)
await listener._apply_header({"height": tip, "hex": chain[tip]}, client)
client.asked.clear()
return listener, client
async def test_the_first_header_pulls_in_the_nine_before_it(session_factory):
"""ELC-22: the window of the last 10 headers is built from the process's first header
and the nine downloaded before it."""
chain = _mine_chain(91, 10)
listener, _client = await _listener_at(session_factory, chain, 100)
assert sorted(listener._window) == list(range(91, 101))
assert listener._window[100] == chain[100]
async def test_the_first_header_is_refused_when_its_predecessors_do_not_chain_into_it(session_factory):
"""ELC-22: the nine headers must be connected to the first one, or the session closes."""
chain = _mine_chain(91, 10)
chain[95] = _mine_chain(95, 1, "ab" * 32)[95] # a header that is not the parent of 96
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 100, "hex": chain[100]}, _HeadersClient(chain))
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
async def test_a_multi_block_jump_is_validated_by_fetching_the_headers_in_between(session_factory):
"""B-74, ELC-12: a jump from tip 100 to 105 used to be accepted on the strength of the
header's own difficulty alone — nothing linked it to the tip. The headers 101..104 are
now fetched from the active server and each must chain; the jump header must chain from
the last of them."""
chain = _mine_chain(91, 15)
listener, client = await _listener_at(session_factory, chain, 100)
await listener._apply_header({"height": 105, "hex": chain[105]}, client)
assert client.asked == [(101, 4)]
assert (listener.tip_height, listener.tip_header_hex) == (105, chain[105])
assert sorted(listener._window) == list(range(96, 106)) # still the last 10
async def test_a_jump_whose_header_is_not_built_on_what_lies_in_between_ends_the_session(session_factory):
"""B-74, ELC-12: the announced header at 105 is real work but not the child of the
server's own header 104 — the server fabricated the tip of an unrelated chain."""
chain = _mine_chain(91, 15)
listener, client = await _listener_at(session_factory, chain, 100)
stranger = _mine_chain(105, 1, "ee" * 32)[105]
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 105, "hex": stranger}, client)
assert (listener.tip_height, listener.tip_header_hex) == (100, chain[100])
async def test_a_jump_with_a_header_that_is_not_proof_of_work_in_between_ends_the_session(session_factory):
"""ELC-12: a header downloaded for the span must pass ELC-11 as well."""
chain = _mine_chain(91, 15)
listener, client = await _listener_at(session_factory, chain, 100)
broken = dict(chain)
broken[103] = _build_header(header_hex_to_block_hash(chain[102]), 0, bits=0x1D00FFFF) # misses its target
client.chain = broken
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 105, "hex": chain[105]}, client)
assert listener.tip_height == 100
async def test_a_jump_the_server_cannot_back_with_headers_ends_the_session(session_factory):
"""B-74: a fake header at a huge height used to lock `tip_height` for good (it never
regresses) and freeze every draw. It now has to bring a connected chain, and a server
that serves none ends the session — the tip stays where it was."""
chain = _mine_chain(91, 10)
listener, client = await _listener_at(session_factory, chain, 100)
far = _mine_chain(5_000_000, 1)[5_000_000]
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 5_000_000, "hex": far}, client)
assert listener.tip_height == 100
async def test_a_long_jump_is_fetched_in_requests_of_at_most_2016_headers(session_factory):
"""ELC-12: blockchain.block.headers serves at most 2016 headers per request."""
chain = _mine_chain(91, 10)
listener, client = await _listener_at(session_factory, chain, 100)
# Only the span's shape matters here: make the server answer with a small `max`.
client.chain = {**chain, **_mine_chain(101, 2501, header_hex_to_block_hash(chain[100]))}
await listener._apply_header({"height": 2601, "hex": client.chain[2601]}, client)
assert client.asked == [(101, 2016), (2117, 484)]
assert listener.tip_height == 2601
async def test_a_fork_is_walked_back_and_followed_instead_of_ending_the_session(session_factory):
"""B-74, ELC-23: an ordinary reorganisation — the server's chain parts from ours at 97
and announces 101 on its own branch — used to close the session on every server (101
does not chain from our 100). The fork point is found inside the window and the
server's branch is adopted."""
chain_a = _mine_chain(91, 10) # ours: 91..100
listener, client = await _listener_at(session_factory, chain_a, 100)
branch_b = _mine_chain(98, 4, header_hex_to_block_hash(chain_a[97]), salt=1) # 98..101
chain_b = {**{h: v for h, v in chain_a.items() if h <= 97}, **branch_b}
client.chain = chain_b
await listener._apply_header({"height": 101, "hex": chain_b[101]}, client)
assert (listener.tip_height, listener.tip_header_hex) == (101, chain_b[101])
assert [listener._window[h] for h in range(98, 102)] == [chain_b[h] for h in range(98, 102)]
assert listener._window[97] == chain_a[97] # the part below the fork is kept
assert sorted(listener._window) == list(range(92, 102))
async def test_a_fork_deeper_than_the_window_is_not_followed(session_factory):
"""ELC-23: the fork point must lie within the last 10 headers; a server on another
chain (or a fork older than the window) ends the session."""
chain_a = _mine_chain(91, 10)
listener, client = await _listener_at(session_factory, chain_a, 100)
other = _mine_chain(91, 11, "dd" * 32, salt=2) # shares nothing with ours
client.chain = other
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 101, "hex": other[101]}, client)
assert (listener.tip_height, listener.tip_header_hex) == (100, chain_a[100])
async def test_a_fork_branch_must_end_at_the_header_the_server_announced(session_factory):
chain_a = _mine_chain(91, 10)
listener, client = await _listener_at(session_factory, chain_a, 100)
branch_b = _mine_chain(98, 4, header_hex_to_block_hash(chain_a[97]), salt=1)
branch_c = _mine_chain(98, 4, header_hex_to_block_hash(chain_a[97]), salt=3)
client.chain = {**{h: v for h, v in chain_a.items() if h <= 97}, **branch_b}
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 101, "hex": branch_c[101]}, client)
assert listener.tip_header_hex == chain_a[100]
async def test_a_header_that_does_not_join_the_tip_and_has_no_connection_to_follow_it_is_refused(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
chain = _mine_chain(100, 1)
await listener._apply_header({"height": 100, "hex": chain[100]})
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 101, "hex": _mine_header("ff" * 32)})
async def test_a_header_easier_than_the_pow_limit_ends_the_session(session_factory, monkeypatch):
"""B-74, ELC-11: with the real powLimit back in force, a header mined at the trivial
target is refused as the first header and as an advance alike."""
from app.rounds import draw
chain = _mine_chain(91, 10)
listener, client = await _listener_at(session_factory, chain, 100)
nxt = _mine_header(header_hex_to_block_hash(chain[100])) # linked, mined, but bits=0x207fffff
monkeypatch.setattr(draw, "POW_LIMIT", draw.MAINNET_POW_LIMIT)
with pytest.raises(HeaderValidationError):
await listener._apply_header({"height": 101, "hex": nxt}, client)
fresh = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
with pytest.raises(HeaderValidationError):
await fresh._apply_header({"height": 100, "hex": chain[100]}, client)
assert (listener.tip_height, fresh.tip_height) == (100, 0)