Files
plm-lottery/tests/unit/test_draw.py
T
davideandClaude Sonnet 5.5 ca50ef62f3 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>
2026-10-14 15:45:34 +02:00

103 lines
4.1 KiB
Python

import pytest
from app.rounds.draw import (
draw_winner,
header_hex_to_block_hash,
header_meets_its_own_target,
header_prev_hash,
)
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
# cross-checked against the blockhash reported by blockchain.transaction.get for a
# tx confirmed in that block.
_REAL_HEADER_HEX = (
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
"e6a47a61b1ad8012582"
)
def test_header_hex_to_block_hash_matches_known_mainnet_block():
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash
def test_header_meets_its_own_target_accepts_a_real_mined_header():
"""B-28: a genuinely mined mainnet header must pass its own self-consistency
check — this isn't just a synthetic-header property."""
assert header_meets_its_own_target(_REAL_HEADER_HEX) is True
def test_header_meets_its_own_target_rejects_a_tampered_header():
"""Flipping a single nonce bit changes the hash completely (avalanche effect)
without changing the claimed difficulty, so a tampered-but-otherwise-real
header should almost certainly fail — this is what would catch a
hostile/MITM'd server replaying a real header with a doctored field."""
tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX))
tampered[-1] ^= 0xFF # flip the last byte of the nonce
assert header_meets_its_own_target(tampered.hex()) is False
def test_header_meets_its_own_target_rejects_wrong_length():
assert header_meets_its_own_target("aa" * 10) is False
def test_header_prev_hash_matches_the_known_previous_block():
# Block 477486's predecessor, 477485 — independently known from the same chain.
assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb"
def test_draw_winner_is_deterministic_and_within_range():
participants = ["addrA", "addrB", "addrC"]
block_hash = "00" * 31 + "05" # seed = 5, index = 5 % 3 = 2
assert draw_winner(participants, block_hash) == "addrC"
def test_draw_winner_single_participant_always_wins():
block_hash = "ff" * 32
assert draw_winner(["only"], block_hash) == "only"
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