"""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 # ---- B-81 (RND-01, RND-15, RND-36, ARC-27, OPS-09): `_tick` resumes a round in `drawing` ---- class _DrawListener(_Listener): """A chain whose tip is already past the confirmations, so a resumed draw needs no waiting.""" tip_height = 503 async def _draw_state(session_factory, round_id: int) -> dict: async with session_factory() as session: round_ = await session.get(Round, round_id) rows = ( await session.scalars( select(RoundParticipant) .where(RoundParticipant.round_id == round_id) .order_by(RoundParticipant.draw_position) ) ).all() return { "status": round_.status, "draw_height": round_.draw_height, "participants_hash": round_.participants_hash, "positions": [(p.user_id, p.draw_position, p.bet_txid) for p in rows], "winner": round_.winner_user_id, "seed_hash": round_.server_seed_hash, } async def test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw(client): """B-81 (RND-36, ARC-27): an exception in the middle of the draw (here the quorum query failing, as a locked database would) leaves the round in `drawing` with everything fixed; the next tick — same scheduler, nothing remembered in memory — completes it with the same height, the same list and the winner the public proof recomputes. Without the `drawing` branch of `_tick` the round stays in `drawing` for ever.""" ac, session_factory = client round_id = await _open_round_with_bets(session_factory, n=6) class _Flaky(_DrawListener): failing = True async def agreed_block_hash(self, height): if self.failing: raise RuntimeError("database is locked") return await super().agreed_block_hash(height) listener = _Flaky({501: _BLOCK_HASH}) listener.tip_height = 500 scheduler = RoundScheduler(session_factory, listener) scheduler._trigger_payout = _noop_payout async def fast_sleep(_seconds): listener.tip_height = 503 import app.rounds.scheduler as scheduler_module original_sleep = scheduler_module.asyncio.sleep scheduler_module.asyncio.sleep = fast_sleep try: try: await scheduler._close_and_draw(round_id) raise AssertionError("the injected failure should have escaped") except RuntimeError: pass before = await _draw_state(session_factory, round_id) assert before["status"] == "drawing" and before["winner"] is None listener.failing = False await scheduler._tick() # the run() loop's next step finally: scheduler_module.asyncio.sleep = original_sleep after = await _draw_state(session_factory, round_id) assert after["status"] == "paying_out" and after["winner"] is not None for fixed in ("draw_height", "participants_hash", "positions", "seed_hash"): assert after[fixed] == before[fixed] proof = (await ac.get(f"/rounds/{round_id}/proof")).json() _, address = _independent_winner(proof) assert proof["winner_address"] == address async def test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing(client): """B-81 (RND-36, OPS-09): a brand-new scheduler (the process restarted) picks the round up from the database alone. Once it is `paying_out` a further tick neither draws again nor starts a second payout trigger from the draw path, and the winner does not change.""" ac, session_factory = client round_id = await _open_round_with_bets(session_factory, n=4) entry_listener = _DrawListener({}) entry_listener.tip_height = 500 entry = RoundScheduler(session_factory, entry_listener) async def stop(_round_id: int) -> None: return None entry._complete_draw = stop # the process "dies" right after the transition await entry._close_and_draw(round_id) before = await _draw_state(session_factory, round_id) assert before["status"] == "drawing" listener = _DrawListener({501: _BLOCK_HASH}) restarted = RoundScheduler(session_factory, listener) payouts: list[int] = [] async def record_payout(rid: int) -> None: payouts.append(rid) restarted._trigger_payout = record_payout retries: list[int] = [] async def record_retry(rid: int) -> None: retries.append(rid) restarted._retry_payout_if_due = record_retry await restarted._tick() after_first = await _draw_state(session_factory, round_id) assert after_first["status"] == "paying_out" assert after_first["draw_height"] == before["draw_height"] == 501 assert after_first["participants_hash"] == before["participants_hash"] assert after_first["positions"] == before["positions"] assert payouts == [round_id] asked_after_first = list(listener.asked) await restarted._tick() # paying_out now: handled by the payout retry, not by a new draw assert listener.asked == asked_after_first assert payouts == [round_id] and retries == [round_id] assert (await _draw_state(session_factory, round_id))["winner"] == after_first["winner"] async def test_resuming_after_a_failure_inside_the_draw_transaction_leaves_no_half_state(client, monkeypatch): """B-81 (RND-36): the exception comes from the very step that stores the winner. The transaction rolls back as a whole — no winner, no revealed secret, still `drawing` — and the next tick completes it.""" ac, session_factory = client round_id = await _open_round_with_bets(session_factory, n=3) listener = _DrawListener({501: _BLOCK_HASH}) scheduler = RoundScheduler(session_factory, listener) scheduler._trigger_payout = _noop_payout entry_listener = _DrawListener({}) entry_listener.tip_height = 500 entry = RoundScheduler(session_factory, entry_listener) async def stop(_round_id: int) -> None: return None entry._complete_draw = stop await entry._close_and_draw(round_id) import app.rounds.scheduler as scheduler_module real_audit = scheduler_module.write_audit_log async def failing_audit(session, event_type, *args, **kwargs): if event_type == "winner_drawn": raise RuntimeError("database is locked") return await real_audit(session, event_type, *args, **kwargs) monkeypatch.setattr(scheduler_module, "write_audit_log", failing_audit) try: await scheduler._tick() raise AssertionError("the injected failure should have escaped") except RuntimeError: pass async with session_factory() as session: round_ = await session.get(Round, round_id) assert (round_.status, round_.winner_user_id, round_.server_seed) == ("drawing", None, None) monkeypatch.setattr(scheduler_module, "write_audit_log", real_audit) await scheduler._tick() async with session_factory() as session: round_ = await session.get(Round, round_id) assert round_.status == "paying_out" and round_.winner_user_id is not None and round_.server_seed