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

-13
View File
@@ -124,19 +124,6 @@ quorum to agree (on disagreement wait and re-ask for the same height). Seed =
mod n. Reveal `server_seed` once the winner is persisted, and expose the full proof
at `GET /rounds/{id}/proof`.
### B-74 — Headers are accepted without a minimum-difficulty check, and a jump skips the linkage check
`header_meets_its_own_target` never compares `bits` against the chain's powLimit, so
a header with `bits=0x207fffff` passes with no work. A multi-block jump is not
linked to the current tip. A fake header at a huge height locks `tip_height` (it
never regresses) and freezes every draw. Conversely an ordinary 1-block reorg kills
the session on every server until the next block.
Fix: reject `bits` easier than powLimit; on any advance of more than one block fetch
the intermediate headers (`blockchain.block.headers`) and validate the chain; on a
linkage mismatch walk back up to 10 headers to find the fork point and follow the
reorg instead of ending the session (reorg handling: B-85).
### B-79 — A payout normally has no change output, so it cannot be fee-bumped
The pool normally holds exactly the round's bet outputs, the payout spends them all
+3 -3
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 556 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 570 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 556 tests
python -m pytest # all 570 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
@@ -157,7 +157,7 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=<sha256>]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at the tip's own height with a different hash (until B-85 follows it; `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
556 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
570 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+6
View File
@@ -287,6 +287,12 @@ class ElectrumClient:
async def get_block_header(self, height: int) -> str:
return await self.request("blockchain.block.header", [height])
async def get_block_headers(self, start_height: int, count: int) -> dict:
"""`count` consecutive headers from `start_height` (B-74, ELC-12), as the server's
{"count", "hex", "max"}: `hex` is the 80-byte headers concatenated, and the server
may return fewer than asked (never more than its `max`)."""
return await self.request("blockchain.block.headers", [start_height, count])
async def _read_loop(self) -> None:
"""Frame the stream into lines by hand rather than with StreamReader.readline:
readline raises on a line over the limit, and that exception used to end this
+165 -51
View File
@@ -47,6 +47,14 @@ _CORROBORATION_TIMEOUT_SECONDS = 10
# of simultaneous in-flight requests against the one active connection.
_RESUBSCRIBE_CONCURRENCY = 20
# How many of the latest accepted headers the listener keeps (B-74/B-85, ELC-22): what lets
# it recognise a reorganisation and walk back to its fork point instead of mistaking it for
# a hostile server. Also the deepest fork it will follow.
_HEADER_WINDOW = 10
# `blockchain.block.headers` serves at most this many headers per request (ELC-12).
_MAX_HEADERS_PER_REQUEST = 2016
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
@@ -85,6 +93,9 @@ class ElectrumListener:
self._background_tasks: set[asyncio.Task] = set()
self.tip_height: int = 0
self.tip_header_hex: str | None = None
# B-74 (ELC-22): height -> header of the last _HEADER_WINDOW accepted headers, tip
# included. Survives reconnections, not restarts.
self._window: dict[int, str] = {}
self.client: ElectrumClient | None = None
@property
@@ -173,7 +184,7 @@ class ElectrumListener:
try:
header = await client.subscribe_headers()
self._apply_header(header)
await self._apply_header(header, client)
if self.tip_header_hex is None:
# B-64: the header was unusable (no hex) and we have never had a tip,
# so publishing this client would hand every consumer a connection
@@ -202,7 +213,7 @@ class ElectrumListener:
# keepalive are what make the drop observable. Whichever finishes first
# ends the session and sends run() around to the next server.
tasks = [
asyncio.create_task(self._consume_headers(headers_queue)),
asyncio.create_task(self._consume_headers(headers_queue, client)),
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
asyncio.create_task(self._keepalive(client)),
asyncio.create_task(client.wait_closed()),
@@ -277,29 +288,27 @@ class ElectrumListener:
await self.client.subscribe_scripthash(scripthash)
await self.refresh_user(user_id, scripthash)
def _apply_header(self, header: dict) -> None:
"""Record a new chain tip, refusing to move backwards or sideways.
async def _apply_header(self, header: dict, client: ElectrumClient | None = None) -> None:
"""Record a new chain tip after checking it (ELC-08 … ELC-13, ELC-22, ELC-23).
Full reorg handling is out of scope for v1 by explicit design decision, but
the tip must never regress: `_wait_for_next_block` waits for
`tip_height > tip_at_close`, so a lower height would silently add a block to
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.
The tip never regresses, and `tip_height`/`tip_header_hex` always describe the
same block: they are applied together or not at all, since that hex is where a
draw's entropy comes from. A header that is ignored (no hex, below the tip, the
tip itself again) changes nothing and ends nothing.
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.
Everything else must be proven, because a forged header steers a draw (B-28):
it must meet the target it claims *and* that target must be no easier than
PLM's powLimit (B-74, ELC-11), and every advance must be linked to the tip we
hold (ELC-12) — a single block by its `prev_hash`, a jump of several by
downloading the headers in between from `client` and checking each. A header that
fails these raises HeaderValidationError, which (via _consume_headers/_run_once)
ends this session like a dropped connection and rotates to the next server.
Two more headers are refused without ending the session, since neither is
evidence of a hostile server the way the above are (B-64): one carrying no
hex, and one at the height we already hold a header for.
A header that merely does not *join* our tip is not hostile in itself: it is a
reorganisation, and is followed back to its fork point inside the last
_HEADER_WINDOW headers (B-74, ELC-23) instead of ending the session on every
server until the next block arrives. `client` is the active connection the
missing headers are fetched from.
"""
height = header["height"]
header_hex = header.get("hex")
@@ -319,7 +328,7 @@ class ElectrumListener:
)
return
if height < self.tip_height:
if self.tip_header_hex is not None and height < self.tip_height:
logger.warning(
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
height,
@@ -327,38 +336,141 @@ class ElectrumListener:
)
return
if height == self.tip_height and self.tip_header_hex:
# B-64: a second header for the height we already hold one for. Either the
# same block re-announced (nothing to do) or a competing one — a reorg at
# the tip, or a server swapping out the very hash a draw may be about to
# use. The linkage check above cannot speak to this case at all, since
# there is no height advance to check. Whichever it is, the hash committed
# to for a height is not replaced under us: if ours turns out to be the
# orphan, corroborate_header (B-28) refuses to seed a draw from it and the
# draw waits for a further block instead.
if header_hex != self.tip_header_hex:
logger.warning(
"ignoring a competing header at the current tip height %s "
"(reorg at the tip, or a server disagreeing with the rest)",
height,
)
if self.tip_header_hex is not None and height == self.tip_height:
if header_hex == self.tip_header_hex:
return # the same block announced again
# B-64: a second header for the height we hold one for — a reorg at the tip, or a
# server disagreeing with the rest. Not replaced under us (B-85 follows it,
# ELC-09); corroborate_header refuses to seed a draw from an orphan anyway.
logger.warning(
"ignoring a competing header at the current tip height %s "
"(reorg at the tip, or a server disagreeing with the rest)",
height,
)
return
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})"
f"header at height {height} is not a valid proof of work "
"(its hash misses the target it claims, or the target is easier than the powLimit)"
)
self.tip_height = height
self.tip_header_hex = header_hex
if self.tip_header_hex is None:
await self._adopt_first_header(height, header_hex, client)
return
tip_hash = header_hex_to_block_hash(self.tip_header_hex)
if height == self.tip_height + 1:
if header_prev_hash(header_hex) == tip_hash:
self._extend_window([(height, header_hex)])
return
await self._follow_reorg(height, header_hex, client)
return
# A jump of several blocks (a reconnect, a server that was ahead of us): link it to
# the tip by fetching what lies in between — an unlinked jump used to be accepted
# on the strength of its own difficulty alone.
if client is None:
raise HeaderValidationError(f"cannot link the header at height {height} to the tip: no connection to fetch from")
between = await self._fetch_headers(client, self.tip_height + 1, height - self.tip_height - 1)
chain = [*between, header_hex]
self._check_chained(chain, header_prev_hash(chain[0])) # internal linkage and work only
if header_prev_hash(chain[0]) != tip_hash:
await self._follow_reorg(height, header_hex, client)
return
self._extend_window([(self.tip_height + 1 + i, h) for i, h in enumerate(chain)])
async def _fetch_headers(self, client: ElectrumClient, start: int, count: int) -> list[str]:
"""`count` headers from `start` (blockchain.block.headers, at most
_MAX_HEADERS_PER_REQUEST per request). A server that returns fewer than it must,
or something that is not whole 80-byte headers, ends the session."""
headers: list[str] = []
while len(headers) < count:
wanted = min(count - len(headers), _MAX_HEADERS_PER_REQUEST)
reply = await client.get_block_headers(start + len(headers), wanted)
got = reply.get("count") if isinstance(reply, dict) else None
raw = reply.get("hex") if isinstance(reply, dict) else None
if not isinstance(got, int) or not isinstance(raw, str) or not 0 < got <= wanted or len(raw) != got * 160:
raise HeaderValidationError(
f"the server returned an unusable reply for headers {start + len(headers)}..+{wanted}"
)
headers.extend(raw[i : i + 160] for i in range(0, len(raw), 160))
return headers
@staticmethod
def _check_chained(headers: list[str], prev_hash: str) -> None:
"""Every header carries valid work and each one builds on the one before it, the
first on `prev_hash` (ELC-11, ELC-12). Raises HeaderValidationError."""
for header_hex in headers:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError("a downloaded header is not a valid proof of work")
if header_prev_hash(header_hex) != prev_hash:
raise HeaderValidationError("the downloaded headers are not a connected chain")
prev_hash = header_hex_to_block_hash(header_hex)
def _extend_window(self, entries: list[tuple[int, str]]) -> None:
"""Make `entries` (consecutive heights, the last the new tip) part of the window,
replacing anything at or above their first height, and move the tip with it."""
first = entries[0][0]
for stale in [h for h in self._window if h >= first]:
del self._window[stale]
self._window.update(entries)
for old in sorted(self._window)[:-_HEADER_WINDOW]:
del self._window[old]
self.tip_height, self.tip_header_hex = entries[-1]
async def _adopt_first_header(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
"""The process's first header has nothing to join, so it is taken on its work alone
— and the (up to) 9 headers before it are downloaded and must chain into it,
which is what the window of ELC-22 is made of."""
entries: list[tuple[int, str]] = []
if client is not None and height > 0:
count = min(_HEADER_WINDOW - 1, height)
before = await self._fetch_headers(client, height - count, count)
first_prev = header_prev_hash(before[0])
self._check_chained(before, first_prev)
if header_prev_hash(header_hex) != header_hex_to_block_hash(before[-1]):
raise HeaderValidationError(f"the header at height {height} does not chain from the headers before it")
entries = [(height - count + i, h) for i, h in enumerate(before)]
self._window.clear()
self._extend_window([*entries, (height, header_hex)])
async def _follow_reorg(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
"""The header at `height` does not build on our tip: find where the server's chain
and ours part (the highest window height at which the server's hash is the same as
ours) and, if that is inside the window, adopt the server's branch from there on
(ELC-23). A fork older than the window, or a branch that is not a connected chain
of valid work ending at the announced header, raises HeaderValidationError."""
if client is None:
raise HeaderValidationError(f"the header at height {height} does not join the tip and there is no connection to follow it")
lowest = min(self._window)
ours = [self._window[h] for h in range(lowest, self.tip_height + 1)]
theirs = await self._fetch_headers(client, lowest, len(ours))
fork_height = None
for offset in range(len(ours) - 1, -1, -1):
if header_hex_to_block_hash(theirs[offset]) == header_hex_to_block_hash(ours[offset]):
fork_height = lowest + offset
break
if fork_height is None:
raise HeaderValidationError(
f"the header at height {height} forks more than {_HEADER_WINDOW} blocks below our tip {self.tip_height}"
)
branch = await self._fetch_headers(client, fork_height + 1, height - fork_height)
if branch[-1] != header_hex:
raise HeaderValidationError(f"the server's branch does not end at the header it announced at height {height}")
self._check_chained(branch, header_hex_to_block_hash(self._window[fork_height]))
old_height, old_hex = self.tip_height, self.tip_header_hex
self._extend_window([(fork_height + 1 + i, h) for i, h in enumerate(branch)])
await self._reorg_followed(old_height, old_hex, fork_height)
async def _reorg_followed(self, old_height: int, old_hex: str, fork_height: int) -> None:
logger.warning(
"followed a chain reorganisation: tip %s -> %s, fork at height %s (depth %s)",
old_height,
self.tip_height,
fork_height,
old_height - fork_height,
)
async def _corroborate_majority(
self,
@@ -701,6 +813,8 @@ class ElectrumListener:
return None
if height == self.tip_height and self.tip_header_hex:
return self.tip_header_hex
if height in self._window:
return self._window[height] # validated as it arrived (ELC-22)
async def _ask(client: ElectrumClient) -> str | None:
header = await client.get_block_header(height)
@@ -738,11 +852,11 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda proven: proven, f"merkle proof of {txid} at height {height}")
async def _consume_headers(self, queue: asyncio.Queue) -> None:
async def _consume_headers(self, queue: asyncio.Queue, client: ElectrumClient | None = None) -> None:
while True:
params = await queue.get()
for header in params:
self._apply_header(header)
await self._apply_header(header, client)
# A new block is exactly what the "drawing" phase is waiting on
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
+13 -2
View File
@@ -9,6 +9,16 @@ _BITS_OFFSET = 72
_BITS_LENGTH = 4
# B-74 (ELC-11): the proof-of-work floor of PLM mainnet — `consensus.powLimit` in
# palladiumcore/src/chainparams.cpp, the same as Bitcoin's. A header claiming a target
# *easier* than this was not mined to the chain's rules, whatever its own hash says, so
# the claimed target alone (which is whoever fabricates the header's to choose) is not a
# proof of work. Read through the module at call time so a test can swap in a regtest
# limit to mine headers cheaply; production code never changes it.
MAINNET_POW_LIMIT = int("00000000" + "ff" * 28, 16)
POW_LIMIT = MAINNET_POW_LIMIT
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
@@ -48,7 +58,8 @@ def _target_from_bits(bits: int) -> int:
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 —
itself* (the `bits` field), and that target is no easier than the chain's powLimit
(B-74, ELC-11 — without that, a fabricated header just claims `bits = 0x207fffff`). 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.
@@ -66,7 +77,7 @@ def header_meets_its_own_target(header_hex: str) -> bool:
return False
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
target = _target_from_bits(bits)
if target <= 0:
if target <= 0 or target > POW_LIMIT: # B-74: not easier than the chain's powLimit
return False
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
# The hash as the integer comparable against `target`: this is the same digest
@@ -1,6 +1,6 @@
# ADR-26 — Validazione degli header concatenata, senza ricalcolo della difficoltà
- **Stato:** Accettata — implementazione da completare (B-74, B-85)
- **Stato:** Accettata — implementazione da completare (B-85)
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -32,8 +32,7 @@ in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-cr
quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)) e il fatto che il seme dipende anche da `server_seed`.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` ·
*Test:* `tests/unit/test_electrum_listener.py::test_apply_header_rejects_one_that_does_not_chain_from_the_tip`, `tests/unit/test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height`
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessun confronto con
`powLimit`, concatenazione verificata solo sull'avanzamento di un blocco
esatto, un header concorrente alla stessa altezza ignorato, un header che non
si concatena chiude la sessione.
*Test:* `tests/unit/test_electrum_listener.py::test_a_multi_block_jump_is_validated_by_fetching_the_headers_in_between`, `tests/unit/test_electrum_listener.py::test_a_fork_is_walked_back_and_followed_instead_of_ending_the_session`, `tests/unit/test_draw.py::test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target`
*Stato:* da implementare (B-85), per l'header concorrente alla stessa
altezza · *Oggi:* un header concorrente alla stessa altezza della punta è
ignorato invece di essere seguito.
@@ -44,6 +44,7 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* —
*Stato:* da implementare (B-82, B-85) · *Oggi:* nessuna riorganizzazione è
rilevata; un header concorrente è ignorato, uno che non si concatena chiude
la sessione; il pagamento non verifica le monete del round.
*Stato:* da implementare (B-82, B-85) · *Oggi:* una riorganizzazione è
seguita ma non registrata e non mette in pausa la lotteria; un header
concorrente alla stessa altezza è ignorato; il pagamento non verifica le
monete del round.
+5 -4
View File
@@ -216,7 +216,7 @@ sono vuoti e non sono elencati.
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73) |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
@@ -539,9 +539,10 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-74, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena
e non confronta la propria punta con gli altri server.
*Stato:* da implementare (B-85, B-86) · *Oggi:* una riorganizzazione
è seguita ma non registrata né segnalata con una pausa, un header
concorrente alla stessa altezza è ignorato, e il listener non confronta la
propria punta con gli altri server.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
+8 -24
View File
@@ -151,10 +151,6 @@ flowchart TB
F -- "Non trovato" --> X
```
*Comportamento atteso.* Oggi un salto di più blocchi non viene collegato, la
difficoltà non è confrontata con il powLimit, e un header che non si aggancia
chiude la sessione.
Tre casi vengono semplicemente **ignorati**, con un avviso nei log e senza
chiudere la sessione, perché non indicano un server ostile:
@@ -197,11 +193,6 @@ passare a un altro server.
([ELC-15]).
*Codice:* `app/rounds/draw.py:header_meets_its_own_target`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74), per il confronto con il powLimit ·
*Oggi:* si verifica solo che l'hash rispetti il bersaglio che l'header
stesso dichiara. Poiché è chi fabbrica l'header a scegliere `bits`, il
controllo non respinge un header mai minato: con `bits = 0x207fffff`
(bersaglio `0x7fffff · 2²³²`, appena sotto 2²⁵⁵) lo passa un header casuale con probabilità 1/2.
- **[ELC-12]** **Ogni** avanzamento della punta deve essere collegato alla
punta corrente. Un avanzamento di un blocco deve avere come `prev_hash`
l'hash della punta. Per un avanzamento di più blocchi, dalla punta `T` a
@@ -213,16 +204,8 @@ passare a un altro server.
(`T+1`, o `H` stesso per un avanzamento di un blocco) a non agganciarsi
alla punta, non si chiude la sessione: è una possibile riorganizzazione e
si segue [ELC-23].
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/client.py:ElectrumClient.request` ·
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._fetch_headers`, `app/electrum/client.py:ElectrumClient.get_block_headers` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74) · *Oggi:* il collegamento si verifica solo
per un avanzamento di un blocco; un salto di più blocchi è accettato con il
solo controllo di difficoltà. Un avanzamento di un blocco che non si
aggancia chiude la sessione: per questo una normale riorganizzazione di un
blocco (la nostra punta `N` diventa orfana) fa chiudere la sessione su
**ogni** server — ciascuno annuncia `N+1` sul nuovo ramo, che non si
aggancia alla nostra `N` — finché l'arrivo di `N+2` non rende l'avanzamento
un salto di due blocchi, accettato senza controllo di collegamento.
- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della
punta (mai uno senza l'altro) e la finestra di [ELC-22]. Ogni notifica di
nuovi header invia una notifica SSE, perché è proprio ciò che l'estrazione
@@ -236,9 +219,8 @@ passare a un altro server.
superare [ELC-11] ed essere collegati fra loro e al primo, altrimenti la
sessione si chiude. La finestra sopravvive alle riconnessioni, non ai
riavvii.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._run_once` · *Test:* —
*Stato:* da implementare (B-85) · *Oggi:* si tiene solo la punta
(`tip_height`, `tip_header_hex`).
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._adopt_first_header`, `app/electrum/listener.py:ElectrumListener._extend_window` ·
*Test:* `tests/unit/test_electrum_listener.py`
### Riorganizzazioni
@@ -252,9 +234,11 @@ passare a un altro server.
il punto di biforcazione e il nuovo header diventa la punta. Se non lo
trova entro 10 header (una biforcazione più profonda, o un server su
un'altra catena), la sessione si chiude come per [ELC-11].
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · *Test:* —
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessuna riorganizzazione è
rilevata né seguita; vedi l'*Oggi* di [ELC-12].
*Codice:* `app/electrum/listener.py:ElectrumListener._follow_reorg` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-85), per l'header con hash diverso alla stessa
altezza della punta · *Oggi:* un header concorrente alla stessa altezza è
ignorato ([ELC-09]); gli altri casi sono seguiti.
- **[ELC-24]** Ogni riorganizzazione seguita scrive l'evento
`chain_reorg_detected` con la profondità (numero dei nostri blocchi
sostituiti, cioè vecchia altezza della punta meno altezza del punto di
+7 -9
View File
@@ -324,16 +324,14 @@ implementato il [piano delle correzioni](piano-correzioni.md).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw`
- **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà
reale.** Oggi il bersaglio non è confrontato con il limite di rete: un server
può dichiarare `bits=0x207fffff` e l'header passa **senza alcun lavoro**
(la versione precedente di questa voce parlava di «hardware modesto»,
sottostimando il problema). La concatenazione si verifica solo per un
avanzamento di un blocco esatto, quindi un header falso a un'altezza enorme
blocca `tip_height`, che non regredisce, e con esso ogni estrazione.
reale.** Il bersaglio dichiarato non può essere più facile del limite di rete
(B-74, [ELC-11](connessione-electrum.md#4-header-validati-non-creduti)) e
ogni salto di più blocchi è verificato come catena, quindi un header falso a
un'altezza enorme non blocca più `tip_height`.
- *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)); non protegge dal blocco di `tip_height`.
- *Stato:* da chiudere con B-74.
- *Per chiuderlo:* bersaglio non più facile di `powLimit` e catena
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)); non proteggeva dal blocco di `tip_height`, ora chiuso da B-74.
- *Stato:* chiuso da B-74.
- *Come è chiuso:* bersaglio non più facile di `powLimit` e catena
verificata su ogni salto ([ADR-26](adr/ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md)). Resta accettato dopo il piano: senza
il ricalcolo della difficoltà, un header a difficoltà minima (`powLimit`,
in media 2³² hash) costa una frazione di secondo a un miner ASIC e pochi
+3 -4
View File
@@ -66,10 +66,9 @@ ricompensa di quel blocco.
confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)).
*Oggi* (B-73): il seme è l'hash del primo blocco visto dopo la chiusura,
senza segreto né impegno, e l'estrazione è manipolabile da chi mina.
- **Header falsi.** Oggi l'unico controllo di lavoro su un header è che il
suo hash rispetti il bersaglio che l'header stesso dichiara nel campo
`bits`: poiché è chi fabbrica l'header a scegliere `bits`, un header mai
minato con `bits = 0x207fffff` lo passa con probabilità 1/2. Dopo B-74 il
- **Header falsi.** Il bersaglio che un header dichiara nel campo `bits` lo sceglie chi
fabbrica l'header (con `bits = 0x207fffff` un header mai minato passava con
probabilità 1/2): per questo dal B-74 il
bersaglio non può essere più facile del powLimit di PLM, `0x1d00ffff`
([ELC-11](connessione-electrum.md#4-header-validati-non-creduti)), e ogni
salto di più blocchi è verificato come catena. Anche al powLimit un header
+3 -3
View File
@@ -222,7 +222,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-nn | Difetto | Gravità | Commit | Test di regressione | Area |
|---|---|---|---|---|---|
| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | *da registrare* | `test_electrum_listener.py`, `test_draw.py` | ELC |
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX |
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR |
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | *da registrare* | `test_tx_versions.py`, `test_migrations.py`, `test_broadcast.py` | TX |
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (5 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (4 critici,
6 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
+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)