`_tick` skipped `drawing`: the wait for the draw block lived only inside the `_close_and_draw` call that entered it, so any exception (a locked database writing an audit row, a malformed header) or a restart left the round in `drawing` for ever with the pool inside and no new round able to open. Since B-73 everything the draw needs is committed on entering `drawing` (encrypted secret, ordered list with `draw_position`, `participants_hash`, `draw_height`, `draw_confirmations`), so `_tick` now calls `_complete_draw` for a round in `drawing`. `_complete_draw` rereads that state, is idempotent (a round no longer `drawing`, or with a winner, is left alone; the winner write is re-checked under BEGIN IMMEDIATE) and runs inside the single scheduler loop, so the inline call from `_close_and_draw` and the tick can never run concurrently. Tests (test_draw_proof.py): an exception in the quorum query, then a tick, completes with the same height, list, fingerprint and the winner the public proof recomputes; a brand-new scheduler (restart) resumes and a second tick draws and triggers nothing again; a failure in the very transaction that stores the winner leaves no winner and no revealed secret, and the next tick completes. Mutation-checked: without the `_tick` branch all three fail. Docs: the B-81 `*Stato:*`/`*Oggi:*` lines are gone (RND-01, RND-15, RND-36, ARC-27, OPS-09, ADR-12, ADR-25), OPS-13 no longer prescribes the manual `closing` workaround, LIM-01 is struck, storico-bug.md and BUGS.md updated. No existing test changed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
524 lines
23 KiB
Python
524 lines
23 KiB
Python
"""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
|