The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
174 lines
6.8 KiB
Python
174 lines
6.8 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"
|
|
|
|
|
|
# --- B-73 (RND-14, RND-17, RND-34): the commit-reveal draw -------------------------------
|
|
|
|
_SEED = "11" * 32
|
|
_BLOCK = "00" * 28 + "deadbeef"
|
|
_LIST_HASH = "22" * 32
|
|
|
|
|
|
def test_draw_seed_is_the_sha256_of_the_three_32_byte_values_read_big_endian():
|
|
"""RND-17: the algorithm, written out here independently of the code under test."""
|
|
import hashlib
|
|
|
|
from app.rounds.draw import draw_seed
|
|
|
|
expected = int.from_bytes(
|
|
hashlib.sha256(bytes.fromhex(_SEED) + bytes.fromhex(_BLOCK) + bytes.fromhex(_LIST_HASH)).digest(), "big"
|
|
)
|
|
assert draw_seed(_SEED, _BLOCK, _LIST_HASH) == expected
|
|
|
|
|
|
def test_draw_seed_depends_on_all_three_inputs():
|
|
from app.rounds.draw import draw_seed
|
|
|
|
base = draw_seed(_SEED, _BLOCK, _LIST_HASH)
|
|
assert draw_seed("12" * 32, _BLOCK, _LIST_HASH) != base # without the secret nobody can predict it
|
|
assert draw_seed(_SEED, "00" * 28 + "deadbeee", _LIST_HASH) != base
|
|
assert draw_seed(_SEED, _BLOCK, "23" * 32) != base # nor can the list change after the block
|
|
|
|
|
|
def test_draw_winner_is_deterministic_and_within_range():
|
|
from app.rounds.draw import draw_index, draw_seed
|
|
|
|
participants = ["addrA", "addrB", "addrC"]
|
|
winner = draw_winner(participants, _SEED, _BLOCK, _LIST_HASH)
|
|
assert winner == participants[draw_seed(_SEED, _BLOCK, _LIST_HASH) % 3]
|
|
assert winner == draw_winner(participants, _SEED, _BLOCK, _LIST_HASH)
|
|
assert 0 <= draw_index(draw_seed(_SEED, _BLOCK, _LIST_HASH), 3) < 3
|
|
|
|
|
|
def test_draw_winner_single_participant_always_wins():
|
|
assert draw_winner(["only"], _SEED, "ff" * 32, _LIST_HASH) == "only"
|
|
|
|
|
|
def test_draw_winner_raises_on_empty_participants():
|
|
with pytest.raises(ValueError):
|
|
draw_winner([], _SEED, _BLOCK, _LIST_HASH)
|
|
|
|
|
|
def test_equal_odds_every_position_can_win():
|
|
"""RND-17: one position per participant, whatever the amount — the index is uniform
|
|
over 0..n-1 (checked over many seeds, not exactly)."""
|
|
from collections import Counter
|
|
|
|
from app.rounds.draw import draw_index, draw_seed
|
|
|
|
wins = Counter(draw_index(draw_seed(f"{i:064x}", _BLOCK, _LIST_HASH), 4) for i in range(2000))
|
|
assert set(wins) == {0, 1, 2, 3}
|
|
assert min(wins.values()) > 400 # about 500 each
|
|
|
|
|
|
def test_server_seed_commitment_is_sha256_of_the_32_bytes():
|
|
import hashlib
|
|
|
|
from app.rounds.draw import server_seed_commitment
|
|
|
|
assert server_seed_commitment(_SEED) == hashlib.sha256(bytes.fromhex(_SEED)).hexdigest()
|
|
|
|
|
|
def test_new_server_seeds_are_32_random_bytes_in_hex():
|
|
from app.rounds.draw import new_server_seed
|
|
|
|
seeds = {new_server_seed() for _ in range(20)}
|
|
assert len(seeds) == 20
|
|
assert all(len(seed) == 64 and bytes.fromhex(seed) for seed in seeds)
|
|
|
|
|
|
def test_participants_hash_encodes_one_txid_vout_address_line_per_participant():
|
|
"""RND-14: sha256 of `txid:vout:address\n` lines in draw_position order, UTF-8."""
|
|
import hashlib
|
|
|
|
from app.rounds.draw import participants_hash
|
|
|
|
rows = [("aa" * 32, 0, "plm1qfirst"), ("bb" * 32, 1, "plm1qsecond")]
|
|
text = f"{'aa' * 32}:0:plm1qfirst\n{'bb' * 32}:1:plm1qsecond\n"
|
|
assert participants_hash(rows) == hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
assert participants_hash(list(reversed(rows))) != participants_hash(rows) # the order is part of it
|
|
|
|
|
|
# --- 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
|