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>
100 lines
5.0 KiB
Python
100 lines
5.0 KiB
Python
import hashlib
|
|
|
|
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
|
|
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
|
|
_HEADER_LENGTH_BYTES = 80
|
|
_PREV_BLOCK_OFFSET = 4
|
|
_PREV_BLOCK_LENGTH = 32
|
|
_BITS_OFFSET = 72
|
|
_BITS_LENGTH = 4
|
|
|
|
|
|
# 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
|
|
previously accepted tip. Letting this propagate out of the header-consuming
|
|
task ends the current Electrum session the same way a dropped connection would
|
|
(see ElectrumListener._run_once), so the listener rotates to the next
|
|
configured server instead of trusting a header a server just forged."""
|
|
|
|
|
|
def header_hex_to_block_hash(header_hex: str) -> str:
|
|
"""Block hash from a raw Electrum header: sha256d, byte-reversed, hex.
|
|
Verified against a real mainnet block (blockchain.transaction.get's own
|
|
reported blockhash) during development."""
|
|
header_bytes = bytes.fromhex(header_hex)
|
|
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
|
|
return digest[::-1].hex()
|
|
|
|
|
|
def header_prev_hash(header_hex: str) -> str:
|
|
"""The header's `prev_block` field, byte-reversed to the same conventional
|
|
(display) order as header_hex_to_block_hash's return value, so the two can be
|
|
compared directly to check that one header actually chains from another."""
|
|
header_bytes = bytes.fromhex(header_hex)
|
|
prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH]
|
|
return prev[::-1].hex()
|
|
|
|
|
|
def _target_from_bits(bits: int) -> int:
|
|
"""Decompress Bitcoin-style compact `nBits` difficulty encoding into the full
|
|
256-bit target a valid header's hash must be less than or equal to."""
|
|
exponent = bits >> 24
|
|
mantissa = bits & 0xFFFFFF
|
|
if exponent <= 3:
|
|
return mantissa >> (8 * (3 - exponent))
|
|
return mantissa << (8 * (exponent - 3))
|
|
|
|
|
|
def header_meets_its_own_target(header_hex: str) -> bool:
|
|
"""Whether this header's hash satisfies the difficulty target *it claims for
|
|
itself* (the `bits` field), 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.
|
|
|
|
This does NOT — and, short of downloading and validating the full header
|
|
chain's difficulty-retarget history, cannot — catch a header honestly mined at
|
|
a real but implausibly low self-chosen difficulty: a server could still declare
|
|
an easy target and grind it out with modest hardware. That residual risk is why
|
|
the draw additionally requires the winning block's header to be corroborated by
|
|
the *other* configured servers before using it as the seed (see
|
|
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
|
|
rather than relying on this check alone.
|
|
"""
|
|
header_bytes = bytes.fromhex(header_hex)
|
|
if len(header_bytes) != _HEADER_LENGTH_BYTES:
|
|
return False
|
|
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
|
|
target = _target_from_bits(bits)
|
|
if target <= 0 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
|
|
# header_hex_to_block_hash reverses into the conventional display hex, so
|
|
# reading it byte-reversed as a big-endian int is equivalent to reading the
|
|
# original digest bytes as little-endian — both give the same integer.
|
|
hash_int = int.from_bytes(digest, "little")
|
|
return hash_int <= target
|
|
|
|
|
|
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
|
|
"""v1 draw algorithm (docs/ciclo-di-vita-del-round.md): seed = block hash as an
|
|
integer, index = seed mod participant_count, winner = participants[index]. Anyone
|
|
can recompute and verify it from public data. Deliberately simple/replaceable."""
|
|
if not participants:
|
|
raise ValueError("no participants to draw from")
|
|
seed = int(block_hash_hex, 16)
|
|
index = seed % len(participants)
|
|
return participants[index]
|