"""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