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:
1 parent
a3c51719bd
commit
ca50ef62f3
16 files changed
+494
-159
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user