Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)

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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:29:09 +02:00
1 parent 82d078e2d6
commit db24287ea5
42 files changed
+1715 -620

No files matched your search

+348
View File
@@ -0,0 +1,348 @@
"""B-73 (RND-34, RND-37, API-45, DAT-64, SEC-56): the commit-reveal draw, end to end, and
what the public can and cannot see of it.
A round is opened by the real service (so it carries its real encrypted secret), filled with
confirmed bets, closed and drawn by the real scheduler against a listener that answers the
quorum, and then read back through `GET /rounds/{id}/proof` — where the winner is recomputed
here, from public data only and with the algorithm written out again independently of the
code under test.
"""
import hashlib
import json
import logging
import re
import sqlite3
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db.models import AuditLog, Round, RoundConfig, RoundParticipant, User
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_pool_address
from app.wallet.keystore import decrypt_round_seed
from tests.unit.test_rounds_route import client # noqa: F401 (fixture)
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
_BLOCK_HASH = "00000000" + "5a" * 28
class _Listener:
client = object()
tip_height = 500
tip_header_hex = "00"
def __init__(self, blocks: dict[int, str]):
self.blocks = blocks
self.asked: list[int] = []
async def agreed_block_hash(self, height: int):
self.asked.append(height)
return self.blocks.get(height), self.blocks.get(height)
async def _noop_payout(round_id: int) -> None:
return None
def _chain_advances_while_waiting(monkeypatch, listener, to_height: int = 503) -> None:
"""The draw is entered at tip 500 (draw height 501); its three confirmations arrive while
it waits — modelled by making the scheduler's sleep move the chain on instead of sleeping."""
import app.rounds.scheduler as scheduler_module
async def fake_sleep(_seconds):
listener.tip_height = to_height
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
async def _open_round_with_bets(session_factory, n: int) -> int:
async with session_factory() as session:
session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=3))
await session.commit()
round_ = await open_new_round_if_needed(session)
await session.commit()
round_.status = "closing"
round_.closed_at = datetime.now(timezone.utc)
for i in range(n):
user = User(username=f"p{i}", password_hash="x", derivation_index=i, address=derive_pool_address(2000 + i))
session.add(user)
await session.flush()
session.add(
RoundParticipant(
round_id=round_.id,
user_id=user.id,
bet_amount_sats=999_000_000,
bet_txid=hashlib.sha256(f"bet{i}".encode()).hexdigest(),
bet_vout=0,
status="confirmed",
broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=n - i),
)
)
await session.commit()
return round_.id
async def _all_text_in_the_database(session_factory) -> list[str]:
"""Every string stored in any table — columns and audit payloads alike."""
from app.db import base as db_base
path = db_base.engine.url.database
with sqlite3.connect(path) as conn:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
values: list[str] = []
for table in tables:
for row in conn.execute(f'SELECT * FROM "{table}"'):
values.extend(str(v) for v in row if isinstance(v, (str, bytes)))
return values
def _independent_winner(proof: dict) -> tuple[int, str]:
"""The verification of docs/ciclo-di-vita-del-round.md, steps 1-6, from the proof alone."""
assert hashlib.sha256(bytes.fromhex(proof["server_seed"])).hexdigest() == proof["server_seed_hash"]
participants = proof["participants"]
assert [p["draw_position"] for p in participants] == list(range(len(participants)))
assert [p["txid"] for p in participants] == sorted(p["txid"] for p in participants)
lines = "".join(f"{p['txid']}:{p['vout']}:{p['address']}\n" for p in participants)
assert hashlib.sha256(lines.encode("utf-8")).hexdigest() == proof["participants_hash"]
material = (
bytes.fromhex(proof["server_seed"])
+ bytes.fromhex(proof["draw_block_hash"])
+ bytes.fromhex(proof["participants_hash"])
)
seed = int.from_bytes(hashlib.sha256(material).digest(), "big")
assert str(seed) == proof["seed"]
index = seed % len(participants)
return index, participants[index]["address"]
async def test_a_finished_draw_can_be_recomputed_from_the_public_proof(client, caplog, monkeypatch):
"""RND-17 / RND-37 / API-45: anyone can redo the calculation from the proof and get the
winner the server stored. Also SEC-48: the secret never reaches the application log."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=5)
listener = _Listener({501: _BLOCK_HASH})
_chain_advances_while_waiting(monkeypatch, listener)
scheduler = RoundScheduler(session_factory, listener)
scheduler._trigger_payout = _noop_payout
with caplog.at_level(logging.DEBUG, logger="app"):
await scheduler._close_and_draw(round_id)
assert listener.asked == [501] # tip 500 + 1, asked for exactly that height
response = await ac.get(f"/rounds/{round_id}/proof")
assert response.status_code == 200
proof = response.json()
assert proof["algorithm"] == "commit-reveal-v1"
assert proof["status"] == "paying_out"
assert proof["draw_height"] == 501 and proof["draw_confirmations"] == 3
assert proof["draw_block_hash"] == _BLOCK_HASH
assert len(proof["participants"]) == 5
assert all(p["vout"] == 0 and p["status"] == "confirmed" for p in proof["participants"])
index, address = _independent_winner(proof)
assert (proof["winner_index"], proof["winner_address"]) == (index, address)
async with session_factory() as session:
round_ = await session.get(Round, round_id)
winner = await session.get(User, round_.winner_user_id)
assert winner.address == address
assert proof["pool_address"] == round_.pool_address
application_log = "\n".join(r.getMessage() for r in caplog.records if r.name.startswith("app"))
assert application_log # something was logged ...
assert proof["server_seed"] not in application_log # ... and never the secret (SEC-48)
async def test_the_secret_is_nowhere_in_the_database_or_the_api_before_the_reveal(client):
"""B-73 / DAT-64 / SEC-56: from the opening to the moment the winner is stored, the
plaintext secret is in no database column and no audit row, and no endpoint returns it —
only its encrypted form and its hash are stored. Checked at every stage."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=3)
async with session_factory() as session:
seed = decrypt_round_seed((await session.get(Round, round_id)).server_seed_enc)
async def assert_hidden(stage: str) -> None:
for value in await _all_text_in_the_database(session_factory):
assert seed not in value, f"the plaintext secret is stored in the database ({stage})"
for path in ("/rounds/current", f"/rounds/{round_id}/proof"):
body = (await ac.get(path)).text
assert seed not in body, f"{path} returns the secret ({stage})"
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
assert proof["server_seed"] is None
assert proof["server_seed_hash"] == hashlib.sha256(bytes.fromhex(seed)).hexdigest()
await assert_hidden("closing") # the commitment is public from the opening
# Entering `drawing` and waiting for the block: the draw's wait is interrupted before
# the winner is stored.
scheduler = RoundScheduler(session_factory, _Listener({}))
async def stop_before_the_draw(_round_id: int) -> None:
return None
scheduler._complete_draw = stop_before_the_draw
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
assert (await session.get(Round, round_id)).status == "drawing"
await assert_hidden("drawing")
# Now the draw completes: the secret appears, and only now.
listener = _Listener({501: _BLOCK_HASH})
listener.tip_height = 503
finishing = RoundScheduler(session_factory, listener)
finishing._trigger_payout = _noop_payout
await finishing._complete_draw(round_id)
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
assert proof["server_seed"] == seed
async with session_factory() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "winner_drawn"))).all()
payload = json.loads(rows[0].payload_json)
assert payload["server_seed"] == seed # AUD-24: revealed in the same transaction as the winner
assert payload["draw_height"] == payload["block_height"] == 501
assert re.fullmatch(r"[0-9a-f]{64}", payload["seed"])
assert int(payload["seed"], 16) == int(proof["seed"])
async def test_the_proof_of_a_round_in_drawing_has_the_committed_list_but_no_result(client):
"""RND-37: from the entry in `drawing` the list, its fingerprint and the height are
public; the block hash, the seed and the winner are not yet."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=3)
scheduler = RoundScheduler(session_factory, _Listener({}))
async def stop(_round_id: int) -> None:
return None
scheduler._complete_draw = stop
await scheduler._close_and_draw(round_id)
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
assert proof["status"] == "drawing"
assert proof["draw_height"] == 501
assert len(proof["participants"]) == 3 and proof["participants_hash"]
for key in ("server_seed", "draw_block_hash", "seed", "winner_index", "winner_address", "payout_txid"):
assert proof[key] is None
async def test_the_proof_of_a_round_still_open_has_only_the_commitment(client):
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
await session.commit()
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
assert proof["status"] == "open" and re.fullmatch(r"[0-9a-f]{64}", proof["server_seed_hash"])
assert proof["participants"] == [] and proof["participants_hash"] is None and proof["draw_height"] is None
assert proof["server_seed"] is None and proof["pool_address"]
async def test_the_proof_needs_no_authentication_and_404s_for_an_unknown_round(client):
ac, _ = client
response = await ac.get("/rounds/999/proof")
assert response.status_code == 404
assert response.json()["detail"]["code"] == "round_not_found"
assert (await ac.get("/rounds/not-a-number/proof")).status_code == 422
async def test_a_round_from_before_the_commitment_has_an_incomplete_block_hash_proof(client):
"""API-45: a round without a commitment is labelled `block-hash-v0` and carries no list."""
ac, session_factory = client
async with session_factory() as session:
session.add(Round(id=7, status="closed", draw_block_hash="ab" * 32, seed_int="12345"))
await session.commit()
proof = (await ac.get("/rounds/7/proof")).json()
assert proof["algorithm"] == "block-hash-v0"
assert proof["server_seed_hash"] is None and proof["participants"] == [] and proof["winner_address"] is None
assert proof["draw_block_hash"] == "ab" * 32 and proof["seed"] == "12345"
async def test_current_round_publishes_the_commitment_and_what_is_fixed_for_the_draw(client):
"""API-25: server_seed_hash from the opening; participants_hash and draw_height once in
drawing; draw_confirmations always — and never the secret."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=2)
current = (await ac.get("/rounds/current")).json()
assert re.fullmatch(r"[0-9a-f]{64}", current["server_seed_hash"])
assert current["draw_confirmations"] == 3
assert current["participants_hash"] is None and current["draw_height"] is None
assert "server_seed" not in current
scheduler = RoundScheduler(session_factory, _Listener({}))
async def stop(_round_id: int) -> None:
return None
scheduler._complete_draw = stop
await scheduler._close_and_draw(round_id)
current = (await ac.get("/rounds/current")).json()
assert current["draw_height"] == 501 and re.fullmatch(r"[0-9a-f]{64}", current["participants_hash"])
async def test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner(client, monkeypatch):
"""RND-36: everything the draw needs is in the database, so running the completion again
— after an exception, in a new scheduler — gives the same winner, and a round that was
already drawn is not drawn again."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=6)
class _Failing(_Listener):
async def agreed_block_hash(self, height):
raise RuntimeError("database is locked") # e.g. while writing an audit row
failing = _Failing({})
_chain_advances_while_waiting(monkeypatch, failing)
first = RoundScheduler(session_factory, failing)
first._trigger_payout = _noop_payout
try:
await first._close_and_draw(round_id)
except RuntimeError:
pass
async with session_factory() as session:
assert (await session.get(Round, round_id)).status == "drawing"
ok = _Listener({501: _BLOCK_HASH})
ok.tip_height = 503
second = RoundScheduler(session_factory, ok)
second._trigger_payout = _noop_payout
await second._complete_draw(round_id)
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
_, address = _independent_winner(proof)
assert proof["winner_address"] == address
other = _Listener({501: "ff" * 32}) # a different block is offered
other.tip_height = 503
third = RoundScheduler(session_factory, other)
third._trigger_payout = _noop_payout
await third._complete_draw(round_id)
again = (await ac.get(f"/rounds/{round_id}/proof")).json()
assert again["winner_address"] == proof["winner_address"] and again["draw_block_hash"] == _BLOCK_HASH
async def test_a_different_block_gives_a_different_draw_but_never_a_different_list(client):
"""Why the order is public: the same participants with a different block at the draw
height can produce a different winner, while the list itself is fixed beforehand."""
ac, session_factory = client
round_id = await _open_round_with_bets(session_factory, n=8)
scheduler = RoundScheduler(session_factory, _Listener({}))
async def stop(_round_id: int) -> None:
return None
scheduler._complete_draw = stop
await scheduler._close_and_draw(round_id)
list_hash = (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"]
winners = set()
for block in ("01" * 32, "02" * 32, "03" * 32, "04" * 32, "05" * 32, "06" * 32):
async with session_factory() as session:
round_ = await session.get(Round, round_id)
from app.rounds.draw import draw_index, draw_seed
seed = decrypt_round_seed(round_.server_seed_enc)
winners.add(draw_index(draw_seed(seed, block, round_.participants_hash), 8))
assert len(winners) > 1 # the block matters ...
assert (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"] == list_hash # ... the list does not move