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:
1 parent
82d078e2d6
commit
db24287ea5
42 files changed
+1715
-620
No files matched your search
@@ -263,6 +263,8 @@ async def test_config_update_without_changes_logs_nothing(client):
|
||||
{"round_duration_seconds": 0}, # a round that expires the instant it opens
|
||||
{"bet_amount_sats": -1},
|
||||
{"rbf_timeout_seconds": 1},
|
||||
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
|
||||
{"draw_confirmations": 21},
|
||||
],
|
||||
)
|
||||
async def test_config_rejects_unusable_values(client, payload):
|
||||
@@ -390,3 +392,27 @@ async def test_admin_list_rounds_shows_each_rounds_pool_address(client):
|
||||
by_id = {row["id"]: row for row in resp.json()}
|
||||
assert by_id[1]["pool_address"] == derive_pool_address(1)
|
||||
assert by_id[2]["pool_address"] is None
|
||||
|
||||
|
||||
async def test_draw_confirmations_default_to_3_and_are_editable_within_1_to_20(client):
|
||||
"""B-73 (DAT-62, API-30, API-31): the number of blocks the draw waits for on its block."""
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["draw_confirmations"] == 3
|
||||
|
||||
resp = await client.put("/admin/config", headers=headers, json={"draw_confirmations": 6})
|
||||
assert resp.status_code == 200 and resp.json()["draw_confirmations"] == 6
|
||||
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 1})).status_code == 200
|
||||
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 20})).status_code == 200
|
||||
|
||||
|
||||
async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client):
|
||||
"""B-73 (API-36): server_seed_hash and draw_height are listed, server_seed is not."""
|
||||
from app.db import base as db_base
|
||||
from app.db.models import Round
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add(Round(status="closed", server_seed_hash="ab" * 32, server_seed="cd" * 32, draw_height=77))
|
||||
await session.commit()
|
||||
rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json()
|
||||
assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77
|
||||
assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0])
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
@@ -81,6 +83,12 @@ async def test_place_bet_broadcasts_and_records_participant(session_factory):
|
||||
assert client.broadcasted # a raw tx was broadcast
|
||||
assert participant.status == "broadcast"
|
||||
assert participant.bet_txid
|
||||
# B-73 (DAT-63): the outpoint the draw list publishes — the output paying the round's pool
|
||||
# address in the transaction that was actually broadcast.
|
||||
bet_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
async with session_factory() as session:
|
||||
pool_address = (await session.get(Round, participant.round_id)).pool_address
|
||||
assert bet_tx.vout[participant.bet_vout].script_pubkey == script.Script.from_address(pool_address)
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
|
||||
+76
-5
@@ -47,20 +47,91 @@ def test_header_prev_hash_matches_the_known_previous_block():
|
||||
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"]
|
||||
block_hash = "00" * 31 + "05" # seed = 5, index = 5 % 3 = 2
|
||||
assert draw_winner(participants, block_hash) == "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():
|
||||
block_hash = "ff" * 32
|
||||
assert draw_winner(["only"], block_hash) == "only"
|
||||
assert draw_winner(["only"], _SEED, "ff" * 32, _LIST_HASH) == "only"
|
||||
|
||||
|
||||
def test_draw_winner_raises_on_empty_participants():
|
||||
with pytest.raises(ValueError):
|
||||
draw_winner([], "00" * 32)
|
||||
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 ------------
|
||||
|
||||
@@ -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
|
||||
@@ -293,91 +293,6 @@ def _mine_chain(first_height: int, length: int, prev_hash: str = "00" * 32, *, s
|
||||
return chain
|
||||
|
||||
|
||||
async def _endpoint_client_factory(responses: dict[str, object]):
|
||||
"""Builds a client_factory whose fake clients answer blockchain.block.header
|
||||
per-endpoint according to `responses`: a header hex string to agree/disagree
|
||||
with, `None` to simulate an unreachable server, or an Exception instance to
|
||||
simulate a request failure."""
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, answer):
|
||||
self._answer = answer
|
||||
self.closed = False
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def request(self, method, params):
|
||||
assert method == "blockchain.block.header"
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def factory(endpoint):
|
||||
return _FakeClient(responses[endpoint.host])
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_header_true_with_no_other_servers_only_under_allow_no_quorum(session_factory):
|
||||
"""ELC-14 / ELC-19 / B-84: this used to be the rule for everyone. Now only the
|
||||
development override passes with nothing to corroborate against."""
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is True
|
||||
|
||||
|
||||
async def test_corroborate_header_true_when_others_agree(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory):
|
||||
"""The active connection is exactly what a hostile server or a MITM would
|
||||
control — corroborating against it too would defeat the point."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
# first.example (the active endpoint) would raise if ever queried.
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert listener.current_endpoint.host == "first.example"
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_majority_disagrees(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
disagreeing_hex = _mine_header("11" * 32)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_nobody_responds(session_factory):
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
|
||||
|
||||
# --- B-29: a UTXO absent from our own connection's listunspent must be
|
||||
# corroborated by other configured servers before it's treated as genuinely spent
|
||||
# outside the platform. ------------------------------------------------------------
|
||||
@@ -845,38 +760,9 @@ async def test_corroboration_fails_with_no_other_server_configured(session_facto
|
||||
"""ELC-14 / B-84: nothing to corroborate against is a failure, not a pass."""
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
async def test_a_single_responding_server_does_not_decide(session_factory):
|
||||
"""ELC-14 / B-84: two others are configured but only one answers — even though it
|
||||
agrees, one answer is not a quorum. Before B-84 this passed."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": header_hex, "third.example": None}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
# The development override restores the reduced rule.
|
||||
relaxed = ElectrumListener(factory, session_factory, _ENDPOINTS, allow_no_quorum=True)
|
||||
assert await relaxed.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_two_answers_must_both_agree_for_a_strict_majority(session_factory):
|
||||
"""ELC-14 / B-84: with exactly two answers, 1 agree + 1 disagree is not a majority."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
other_hex = _mine_header("11" * 32)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": header_hex, "third.example": other_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
|
||||
async def test_a_credit_is_not_corroborated_by_one_server_alone(session_factory):
|
||||
"""ELC-17 / B-84: the same minimum applies to every kind of corroboration."""
|
||||
entry = {"tx_hash": "77" * 32, "tx_pos": 0, "value": 5_000_000, "height": 10}
|
||||
@@ -1389,3 +1275,135 @@ async def test_a_run_once_session_ends_when_the_active_server_stalls(session_fac
|
||||
clock["now"] = 361.0
|
||||
assert await asyncio.wait_for(task, timeout=3) is True
|
||||
assert listener.client is None
|
||||
|
||||
|
||||
# --- B-73 (RND-16, ELC-15): the draw block's hash, asked of every server at a fixed height --
|
||||
|
||||
|
||||
class _BlockHeaderClient:
|
||||
"""A server's answer to `blockchain.block.header`: a header hex, None for unreachable, or an
|
||||
exception to raise from connect()."""
|
||||
|
||||
def __init__(self, answer, asked: list | None = None, name: str = ""):
|
||||
self._answer = answer
|
||||
self._asked = asked if asked is not None else []
|
||||
self._name = name
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def get_block_header(self, height: int):
|
||||
self._asked.append((self._name, height))
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _header_listener(session_factory, answers: dict[str, object], *, allow_no_quorum=False, endpoints=None):
|
||||
"""A listener whose active server is `first.example` and whose others answer `answers`."""
|
||||
asked: list = []
|
||||
listener = ElectrumListener(
|
||||
lambda endpoint: _BlockHeaderClient(answers[endpoint.host], asked, endpoint.host),
|
||||
session_factory,
|
||||
endpoints or _ENDPOINTS,
|
||||
allow_no_quorum=allow_no_quorum,
|
||||
)
|
||||
listener.client = _BlockHeaderClient(answers["first.example"], asked, "active")
|
||||
listener.asked = asked
|
||||
return listener
|
||||
|
||||
|
||||
async def test_the_draw_block_hash_is_returned_when_every_server_agrees(session_factory):
|
||||
header = _mine_header("00" * 32)
|
||||
listener = _header_listener(
|
||||
session_factory, {"first.example": header, "second.example": header, "third.example": header}
|
||||
)
|
||||
assert await listener.agreed_block_hash(100) == (header_hex_to_block_hash(header),) * 2
|
||||
assert sorted(h for _name, h in listener.asked) == [100, 100, 100] # the same height, from all three
|
||||
|
||||
|
||||
async def test_the_draw_block_hash_asks_the_active_server_too(session_factory):
|
||||
"""RND-16: unlike the other corroborations, the active server is one of the voices —
|
||||
what it reports is also what the audit row records on a disagreement."""
|
||||
header = _mine_header("00" * 32)
|
||||
listener = _header_listener(
|
||||
session_factory, {"first.example": header, "second.example": header, "third.example": header}
|
||||
)
|
||||
await listener.agreed_block_hash(100)
|
||||
assert "active" in {name for name, _h in listener.asked}
|
||||
|
||||
|
||||
async def test_a_lying_active_server_is_outvoted_by_the_others(session_factory):
|
||||
"""ELC-15: the active server announces another block at that height; the majority wins,
|
||||
and the active server's answer is reported separately so the lie is visible."""
|
||||
real = _mine_header("00" * 32)
|
||||
fake = _mine_header("11" * 32)
|
||||
listener = _header_listener(
|
||||
session_factory, {"first.example": fake, "second.example": real, "third.example": real}
|
||||
)
|
||||
agreed, reported = await listener.agreed_block_hash(100)
|
||||
assert agreed == header_hex_to_block_hash(real)
|
||||
assert reported == header_hex_to_block_hash(fake)
|
||||
|
||||
|
||||
async def test_no_strict_majority_means_no_hash(session_factory):
|
||||
"""RND-16: three servers, three different answers — nothing is agreed, and the caller
|
||||
asks again for the same height."""
|
||||
a, b, c = (_mine_header(prev) for prev in ("00" * 32, "11" * 32, "22" * 32))
|
||||
listener = _header_listener(session_factory, {"first.example": a, "second.example": b, "third.example": c})
|
||||
agreed, reported = await listener.agreed_block_hash(100)
|
||||
assert agreed is None and reported == header_hex_to_block_hash(a)
|
||||
|
||||
|
||||
async def test_one_other_server_answering_is_not_a_quorum(session_factory):
|
||||
"""ELC-14 / B-84: two others are configured but only one answers — even in agreement
|
||||
with the active server, that is not enough. ALLOW_NO_QUORUM relaxes it."""
|
||||
header = _mine_header("00" * 32)
|
||||
answers = {"first.example": header, "second.example": header, "third.example": None}
|
||||
listener = _header_listener(session_factory, answers)
|
||||
assert (await listener.agreed_block_hash(100))[0] is None
|
||||
relaxed = _header_listener(session_factory, answers, allow_no_quorum=True)
|
||||
assert (await relaxed.agreed_block_hash(100))[0] == header_hex_to_block_hash(header)
|
||||
|
||||
|
||||
async def test_two_others_agreeing_decide_even_when_the_active_server_is_silent(session_factory):
|
||||
header = _mine_header("00" * 32)
|
||||
listener = _header_listener(
|
||||
session_factory, {"first.example": None, "second.example": header, "third.example": header}
|
||||
)
|
||||
agreed, reported = await listener.agreed_block_hash(100)
|
||||
assert agreed == header_hex_to_block_hash(header) and reported is None
|
||||
|
||||
|
||||
async def test_nobody_answering_agrees_on_nothing(session_factory):
|
||||
listener = _header_listener(
|
||||
session_factory,
|
||||
{"first.example": None, "second.example": None, "third.example": ConnectionRefusedError("down")},
|
||||
)
|
||||
assert await listener.agreed_block_hash(100) == (None, None)
|
||||
|
||||
|
||||
async def test_a_header_without_valid_work_is_not_an_answer(session_factory, monkeypatch):
|
||||
"""ELC-11 applies to the draw block too: a junk header is discarded, not counted."""
|
||||
from app.rounds import draw
|
||||
|
||||
real = _mine_header("00" * 32)
|
||||
monkeypatch.setattr(draw, "POW_LIMIT", draw.MAINNET_POW_LIMIT) # real == easy header: refused
|
||||
listener = _header_listener(
|
||||
session_factory, {"first.example": real, "second.example": real, "third.example": real}
|
||||
)
|
||||
assert await listener.agreed_block_hash(100) == (None, None)
|
||||
|
||||
|
||||
async def test_without_other_servers_only_allow_no_quorum_lets_the_active_one_decide(session_factory):
|
||||
"""ELC-14 / ELC-19: the development override, and only it, takes the lone active answer."""
|
||||
single = [ElectrumEndpoint("first.example", 50002, True)]
|
||||
header = _mine_header("00" * 32)
|
||||
strict = _header_listener(session_factory, {"first.example": header}, endpoints=single)
|
||||
assert (await strict.agreed_block_hash(100))[0] is None
|
||||
dev = _header_listener(session_factory, {"first.example": header}, allow_no_quorum=True, endpoints=single)
|
||||
assert (await dev.agreed_block_hash(100))[0] == header_hex_to_block_hash(header)
|
||||
@@ -160,3 +160,39 @@ def test_failed_at_and_paused_reason_are_backfilled_for_existing_rows(tmp_path):
|
||||
assert reason == "operator"
|
||||
assert _index_sql(db_path, "ix_rounds_single_active") is not None
|
||||
assert _index_sql(db_path, "ix_users_username_lower") is not None
|
||||
|
||||
|
||||
def test_the_commit_reveal_columns_and_the_draw_position_index_exist_at_head(migrated_db):
|
||||
"""B-73 (DAT-62 … DAT-66): the new columns, their defaults and the unique position index."""
|
||||
with sqlite3.connect(migrated_db) as conn:
|
||||
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
||||
for column in ("server_seed_enc", "server_seed", "server_seed_hash", "participants_hash", "draw_height"):
|
||||
assert column in rounds and rounds[column][3] == 0 # nullable
|
||||
assert rounds["draw_confirmations"][3] == 1 and rounds["draw_confirmations"][4] in ("3", "'3'")
|
||||
participants = {r[1]: r for r in conn.execute("PRAGMA table_info(round_participants)")}
|
||||
assert participants["bet_vout"][3] == 1 and participants["bet_vout"][4] in ("0", "'0'")
|
||||
assert participants["draw_position"][3] == 0
|
||||
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
|
||||
assert config["draw_confirmations"][4] in ("3", "'3'")
|
||||
|
||||
sql = _index_sql(migrated_db, "ux_round_participants_round_draw_position")
|
||||
assert sql is not None and "unique" in sql and "(round_id, draw_position)" in sql
|
||||
|
||||
|
||||
def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexist(migrated_db):
|
||||
with sqlite3.connect(migrated_db) as conn:
|
||||
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
|
||||
round_id = conn.execute("SELECT max(id) FROM rounds").fetchone()[0]
|
||||
columns = "(round_id, user_id, bet_amount_sats, bet_txid, broadcast_at, status, draw_position)"
|
||||
|
||||
def insert(user_id, position):
|
||||
conn.execute(
|
||||
f"INSERT INTO round_participants {columns} VALUES (?, ?, 1, 'tx', '2026-01-01 00:00:00', 'confirmed', ?)",
|
||||
(round_id, user_id, position),
|
||||
)
|
||||
|
||||
insert(1, None)
|
||||
insert(2, None) # NULLs do not collide
|
||||
insert(3, 0)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
insert(4, 0)
|
||||
@@ -288,3 +288,64 @@ async def test_cooldown_comes_from_the_round_that_closed(session_factory):
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None # still cooling down
|
||||
|
||||
|
||||
# --- B-73 (RND-34, DAT-61, DAT-64, SEC-56): the commitment made when a round opens --------
|
||||
|
||||
|
||||
async def test_a_new_round_commits_to_an_encrypted_secret_and_copies_draw_confirmations(session_factory):
|
||||
"""The hash is published, the secret is stored only encrypted (and decrypts to the
|
||||
preimage of the hash), the plaintext column stays empty, and the number of confirmations
|
||||
is copied from the config at open — an edit applies from the next round."""
|
||||
import hashlib
|
||||
|
||||
from app.wallet.keystore import decrypt_round_seed
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().draw_confirmations = 5
|
||||
await session.commit()
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.draw_confirmations == 5
|
||||
assert round_.server_seed is None # DAT-64: no plaintext before the reveal
|
||||
assert round_.server_seed_enc and round_.server_seed_hash
|
||||
seed = decrypt_round_seed(round_.server_seed_enc)
|
||||
assert len(bytes.fromhex(seed)) == 32
|
||||
assert hashlib.sha256(bytes.fromhex(seed)).hexdigest() == round_.server_seed_hash
|
||||
assert seed not in round_.server_seed_enc # an encrypted token, not the secret in disguise
|
||||
(await session.scalars(select(RoundConfig))).one().draw_confirmations = 9 # edited mid-round
|
||||
await session.commit()
|
||||
assert (await session.get(Round, round_id)).draw_confirmations == 5
|
||||
|
||||
|
||||
async def test_every_round_gets_its_own_secret(session_factory):
|
||||
seeds = set()
|
||||
for _ in range(3):
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
round_.status = "closed"
|
||||
round_.closed_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
seeds.add(round_.server_seed_hash)
|
||||
await session.commit()
|
||||
assert len(seeds) == 3
|
||||
|
||||
|
||||
async def test_the_secret_cannot_be_decrypted_with_another_key(session_factory, monkeypatch):
|
||||
"""SEC-56: a copy of the database without XPRV_ENCRYPTION_KEY does not reveal it."""
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import decrypt_round_seed
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
token = round_.server_seed_enc
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
with pytest.raises(InvalidToken):
|
||||
decrypt_round_seed(token)
|
||||
+327
-112
@@ -399,165 +399,380 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_
|
||||
assert pending.status == "pending"
|
||||
|
||||
|
||||
# --- B-28: the draw must not seed itself from an uncorroborated header -----------
|
||||
# --- B-73 (RND-14 … RND-20, RND-33, RND-34): the commit-reveal draw ------------------------
|
||||
|
||||
_TIP = 100
|
||||
|
||||
|
||||
class CorroboratingListener:
|
||||
"""A fake listener whose tip advances the moment a corroboration attempt
|
||||
fails, simulating a further block arriving — lets tests drive
|
||||
_wait_for_next_block's retry loop deterministically without real sleeps."""
|
||||
class DrawListener:
|
||||
"""What the draw asks of the listener: a tip that tests move, and the quorum's answer for a
|
||||
height (`blocks`: height -> hash, or None for "no agreement"). Every question is
|
||||
recorded so a test can assert it was always about the *fixed* draw height."""
|
||||
|
||||
def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None):
|
||||
self.tip_height, self.tip_header_hex = next(iter(responses)), "aa"
|
||||
self._responses = dict(responses)
|
||||
self._advance_to = advance_to or {}
|
||||
self.corroboration_calls: list[int] = []
|
||||
client = object()
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
self.corroboration_calls.append(height)
|
||||
result = self._responses[height]
|
||||
if not result and height in self._advance_to:
|
||||
self.tip_height, self.tip_header_hex = self._advance_to[height]
|
||||
return result
|
||||
def __init__(self, tip_height: int = _TIP, blocks: dict[int, str | None] | None = None):
|
||||
self.tip_height = tip_height
|
||||
self.tip_header_hex = "00"
|
||||
self.blocks = blocks if blocks is not None else {}
|
||||
self.asked: list[int] = []
|
||||
self.reported: dict[int, str | None] = {}
|
||||
|
||||
async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]:
|
||||
self.asked.append(height)
|
||||
return self.blocks.get(height), self.reported.get(height, self.blocks.get(height))
|
||||
|
||||
|
||||
async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory):
|
||||
listener = CorroboratingListener(responses={101: True})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 101
|
||||
assert listener.corroboration_calls == [101]
|
||||
def _bet_txid(i: int) -> str:
|
||||
return f"{(0xA0 + i * 7) % 256:02x}" * 32
|
||||
|
||||
|
||||
async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory):
|
||||
"""B-28: an uncorroborated header must never be used — the wait keeps going
|
||||
until a later block's header *is* corroborated, logging why each time."""
|
||||
listener = CorroboratingListener(
|
||||
responses={101: False, 102: True}, advance_to={101: (102, "bb")}
|
||||
)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 102
|
||||
assert listener.corroboration_calls == [101, 102]
|
||||
async def _seed_closing_round(session_factory, *, n: int = 3, draw_confirmations: int = 3, txids=None) -> int:
|
||||
"""A real round opened by `open_new_round_if_needed` (so it carries its real encrypted
|
||||
secret), closed, with `n` confirmed participants whose txids are NOT in broadcast order."""
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_header_corroboration_failed"]
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=draw_confirmations))
|
||||
await session.commit()
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_.status = "closing"
|
||||
round_.closed_at = datetime.now(timezone.utc)
|
||||
txids = txids or [_bet_txid(i) for i in range(n)]
|
||||
for i in range(n):
|
||||
user = User(username=f"player{i}", password_hash="x", derivation_index=i, address=_address(i))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_.id,
|
||||
user_id=user.id,
|
||||
bet_amount_sats=1_000_000_000 - 5_000 * i,
|
||||
bet_txid=txids[i],
|
||||
bet_vout=0,
|
||||
# broadcast order is the *reverse* of txid order for some of them, so an
|
||||
# implementation still sorting by broadcast_at would get a different list
|
||||
broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=i),
|
||||
status="confirmed",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return round_.id
|
||||
|
||||
|
||||
# --- B-63: an unknown tip at closing time must not become the draw's seed --------
|
||||
def _address(i: int) -> str:
|
||||
return derive_pool_address(1000 + i) # any distinct, valid PLM addresses
|
||||
|
||||
|
||||
class LateTipListener:
|
||||
"""A listener that doesn't know the tip yet and learns it only once asked —
|
||||
the state the old code could observe while `client` already looked alive."""
|
||||
async def _enter_drawing_only(scheduler) -> list[int]:
|
||||
"""Run `_close_and_draw` up to (not including) the draw itself."""
|
||||
entered: list[int] = []
|
||||
|
||||
def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]):
|
||||
self.tip_height = 0
|
||||
self.tip_header_hex = None
|
||||
self._learns = learns
|
||||
self._then_advances_to = then_advances_to
|
||||
self.corroboration_calls: list[int] = []
|
||||
async def record(round_id: int) -> None:
|
||||
entered.append(round_id)
|
||||
|
||||
def learn_tip(self) -> None:
|
||||
self.tip_height, self.tip_header_hex = self._learns
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
self.corroboration_calls.append(height)
|
||||
return True
|
||||
scheduler._complete_draw = record
|
||||
return entered
|
||||
|
||||
|
||||
async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block(
|
||||
session_factory, monkeypatch
|
||||
): # B-63
|
||||
"""A tip_at_close of 0 means the tip was *unknown* when the round closed, not
|
||||
that the chain was at height zero. The first header we then learn describes a
|
||||
block that may well predate the close — whose hash was public while bets were
|
||||
still open — so it must become the baseline, never the seed: the draw waits for a
|
||||
block strictly after it."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb"))
|
||||
async def test_closing_with_participants_commits_the_draw_and_enters_drawing(session_factory):
|
||||
"""B-73 (RND-14, RND-33, DAT-65, AUD-37): in one transaction the list is ordered by bet txid
|
||||
and numbered, its fingerprint and the draw height (tip + 1) are stored, and the round
|
||||
enters `drawing` — before any block of the draw exists."""
|
||||
round_id = await _seed_closing_round(session_factory, n=3)
|
||||
listener = DrawListener(tip_height=_TIP)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
entered = await _enter_drawing_only(scheduler)
|
||||
|
||||
async def fake_sleep(_seconds):
|
||||
# First sleep: the tip becomes known (height 500, the pre-close block).
|
||||
# Second: a genuinely new block arrives on top of it.
|
||||
if listener.tip_height == 0:
|
||||
listener.learn_tip()
|
||||
else:
|
||||
listener.tip_height, listener.tip_header_hex = listener._then_advances_to
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
assert entered == [round_id]
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "drawing"
|
||||
assert round_.draw_height == _TIP + 1
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RoundParticipant.draw_position, RoundParticipant.bet_txid, RoundParticipant.user_id)
|
||||
.where(RoundParticipant.round_id == round_id)
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
assert [r.draw_position for r in rows] == [0, 1, 2]
|
||||
assert [r.bet_txid for r in rows] == sorted(_bet_txid(i) for i in range(3)) # ascending txid
|
||||
users = {u.id: u.address for u in (await session.scalars(select(User))).all()}
|
||||
# an independent recomputation of the fingerprint (RND-14)
|
||||
import hashlib
|
||||
|
||||
height, _block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
text = "".join(f"{r.bet_txid}:0:{users[r.user_id]}\n" for r in rows)
|
||||
assert round_.participants_hash == hashlib.sha256(text.encode()).hexdigest()
|
||||
assert round_.pool_amount_sats == sum(1_000_000_000 - 5_000 * i for i in range(3))
|
||||
assert round_.drawing_started_at is not None
|
||||
|
||||
assert height == 501 # the block *after* the one we first learned about
|
||||
assert listener.corroboration_calls == [501] # 500 was never even a candidate
|
||||
committed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_committed"))).all()
|
||||
assert len(committed) == 1 and committed[0].round_id == round_id
|
||||
import json
|
||||
|
||||
assert json.loads(committed[0].payload_json) == {
|
||||
"participants_hash": round_.participants_hash,
|
||||
"draw_height": _TIP + 1,
|
||||
"participants": 3,
|
||||
"server_seed_hash": round_.server_seed_hash,
|
||||
}
|
||||
|
||||
|
||||
async def test_participants_with_equal_broadcast_times_are_still_ordered_by_txid(session_factory):
|
||||
"""RND-14 / DAT-66: the order is a function of public data only. The former order by
|
||||
`broadcast_at` had no tie-break: bets stamped with the same instant could be drawn in
|
||||
either order, so the draw could not be reproduced."""
|
||||
txids = ["cc" * 32, "aa" * 32, "bb" * 32]
|
||||
round_id = await _seed_closing_round(session_factory, n=3, txids=txids)
|
||||
async with session_factory() as session:
|
||||
for p in (await session.scalars(select(RoundParticipant))).all():
|
||||
p.broadcast_at = datetime(2026, 1, 1, tzinfo=timezone.utc) # all identical
|
||||
await session.commit()
|
||||
scheduler = RoundScheduler(session_factory, DrawListener())
|
||||
await _enter_drawing_only(scheduler)
|
||||
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_baseline_tip_unknown"] # explainable from /admin
|
||||
ordered = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant.bet_txid)
|
||||
.where(RoundParticipant.round_id == round_id)
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
assert ordered == sorted(txids)
|
||||
|
||||
|
||||
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
|
||||
async def test_the_draw_height_is_fixed_when_entering_drawing_and_never_moves(session_factory):
|
||||
"""RND-33: tip at closing + 1, stored on the round — the chain advancing afterwards does
|
||||
not change it."""
|
||||
round_id = await _seed_closing_round(session_factory)
|
||||
listener = DrawListener(tip_height=250)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
await _enter_drawing_only(scheduler)
|
||||
await scheduler._close_and_draw(round_id)
|
||||
listener.tip_height = 400
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, round_id)).draw_height == 251
|
||||
|
||||
|
||||
class StallingListener:
|
||||
"""A tip that never advances until the test decides it should — used to drive
|
||||
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
|
||||
without a real 6-minute wait."""
|
||||
async def test_an_unknown_tip_keeps_the_round_closing_and_writes_one_audit_row(session_factory):
|
||||
"""RND-19 / AUD-25: a tip of 0 is "unknown", not a base for the draw height. The round
|
||||
stays `closing` and is retried at the next step; the audit row is written once."""
|
||||
round_id = await _seed_closing_round(session_factory)
|
||||
listener = DrawListener(tip_height=0)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
entered = await _enter_drawing_only(scheduler)
|
||||
|
||||
def __init__(self):
|
||||
self.tip_height = 100
|
||||
self.tip_header_hex = None
|
||||
await scheduler._close_and_draw(round_id)
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
return True
|
||||
assert entered == []
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "closing" and round_.draw_height is None
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_baseline_tip_unknown"))).all()
|
||||
assert len(rows) == 1 and rows[0].payload_json == "{}"
|
||||
listener.tip_height = 80 # the height becomes known: the next step enters drawing
|
||||
await scheduler._close_and_draw(round_id)
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, round_id)).draw_height == 81
|
||||
|
||||
|
||||
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
|
||||
async def test_a_round_without_participants_closes_and_reveals_its_secret(session_factory):
|
||||
"""DAT-64: nothing was drawn, so the secret is revealed at the close."""
|
||||
round_id = await _seed_closing_round(session_factory, n=0)
|
||||
scheduler = RoundScheduler(session_factory, DrawListener())
|
||||
await scheduler._close_and_draw(round_id)
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "closed"
|
||||
assert round_.server_seed is not None
|
||||
from app.rounds.draw import server_seed_commitment
|
||||
|
||||
assert server_seed_commitment(round_.server_seed) == round_.server_seed_hash
|
||||
|
||||
|
||||
async def test_a_round_opened_before_the_commitment_existed_gets_a_secret_when_it_enters_drawing(session_factory):
|
||||
"""Upgrade path: a round with no `server_seed_enc` (opened before B-73) must not wedge — it
|
||||
is given a secret as it enters `drawing`, still before any block of the draw exists."""
|
||||
round_id = await _seed_closing_round(session_factory)
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.server_seed_enc = round_.server_seed_hash = None
|
||||
await session.commit()
|
||||
scheduler = RoundScheduler(session_factory, DrawListener())
|
||||
await _enter_drawing_only(scheduler)
|
||||
await scheduler._close_and_draw(round_id)
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "drawing" and round_.server_seed_enc and round_.server_seed_hash
|
||||
assert round_.server_seed is None
|
||||
|
||||
|
||||
def _fake_clock(monkeypatch, listener, on_sleep=None):
|
||||
"""Drive `_wait_for_draw_block` without real time: `datetime.now` is a clock that only
|
||||
`asyncio.sleep` advances, and `on_sleep(now)` lets the test move the chain."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = StallingListener()
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
start = datetime.now(timezone.utc)
|
||||
|
||||
class _FakeClock:
|
||||
class _Clock:
|
||||
now = start
|
||||
|
||||
def fake_now(tz=None):
|
||||
return _FakeClock.now
|
||||
monkeypatch.setattr(
|
||||
scheduler_module, "datetime", type("_D", (), {"now": staticmethod(lambda tz=None: _Clock.now)})
|
||||
)
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
_FakeClock.now += timedelta(seconds=seconds)
|
||||
# Past the stall threshold, but before it would repeat: unblock the wait
|
||||
# by making a (corroborated) block appear, so the test terminates.
|
||||
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
|
||||
listener.tip_height = 101
|
||||
listener.tip_header_hex = "aa"
|
||||
_Clock.now += timedelta(seconds=seconds)
|
||||
if on_sleep is not None:
|
||||
on_sleep(_Clock.now - start)
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
return start
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
|
||||
|
||||
assert height == 101
|
||||
async def test_the_draw_waits_for_the_confirmations_on_the_draw_block(session_factory, monkeypatch):
|
||||
"""RND-15: draw_height 101 with 3 confirmations needs a chain at 103 or more. The quorum is
|
||||
not even asked before that — and then it is asked about 101, not about the tip."""
|
||||
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
def on_sleep(elapsed):
|
||||
if elapsed >= timedelta(seconds=10):
|
||||
listener.tip_height = 102
|
||||
if elapsed >= timedelta(seconds=20):
|
||||
listener.tip_height = 103
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
|
||||
|
||||
assert block_hash == "ab" * 32
|
||||
assert listener.asked == [101] # only once the chain reached 103 — and about the fixed height
|
||||
|
||||
|
||||
async def test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash(session_factory, monkeypatch):
|
||||
"""RND-16 / AUD-26: no agreement means waiting and asking for the *same* height — never
|
||||
moving to another block. The audit row is written once per distinct hash the active server
|
||||
reports, not on every request."""
|
||||
listener = DrawListener(tip_height=105, blocks={101: None})
|
||||
listener.reported[101] = "ee" * 32
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
def on_sleep(elapsed):
|
||||
if elapsed >= timedelta(seconds=30):
|
||||
listener.blocks[101] = "cd" * 32 # the quorum finally agrees
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
|
||||
|
||||
assert block_hash == "cd" * 32
|
||||
assert set(listener.asked) == {101} and len(listener.asked) > 2
|
||||
async with session_factory() as session:
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all()
|
||||
import json
|
||||
|
||||
assert [json.loads(r.payload_json) for r in rows] == [{"height": 101, "reported_hash": "ee" * 32}]
|
||||
|
||||
# a different hash reported later is a new fact worth a row of its own
|
||||
listener.blocks[101] = None
|
||||
listener.reported[101] = "ff" * 32
|
||||
|
||||
def on_sleep_again(elapsed):
|
||||
if elapsed >= timedelta(seconds=15):
|
||||
listener.blocks[101] = "cd" * 32
|
||||
|
||||
_fake_clock(monkeypatch, listener, on_sleep_again)
|
||||
await scheduler._wait_for_draw_block(1, 101, 3, datetime.now(timezone.utc))
|
||||
async with session_factory() as session:
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all()
|
||||
assert [json.loads(r.payload_json)["reported_hash"] for r in rows] == ["ee" * 32, "ff" * 32]
|
||||
|
||||
|
||||
async def test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw(session_factory, monkeypatch):
|
||||
"""B-73: the active server announces tip 104 with a block of its choosing (and withholds
|
||||
the real one) while the quorum has nothing yet for the fixed draw height 101: the draw
|
||||
must not take 104's hash, nor 102's — it waits for the quorum at 101, however long."""
|
||||
listener = DrawListener(tip_height=104, blocks={101: None, 102: "11" * 32, 104: "44" * 32})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
def on_sleep(elapsed):
|
||||
if elapsed >= timedelta(seconds=60):
|
||||
listener.blocks[101] = "01" * 32
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start)
|
||||
|
||||
assert block_hash == "01" * 32 # the hash at 101, whatever the others looked like
|
||||
assert set(listener.asked) == {101}
|
||||
|
||||
|
||||
async def test_the_stall_clock_runs_only_while_the_chain_does_not_advance(session_factory, monkeypatch):
|
||||
"""RND-20: with 3 confirmations a round waits ~3 blocks. A chain producing a block every
|
||||
120s must not look stalled (the old rule counted from the start and fired in almost every
|
||||
round)."""
|
||||
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
def on_sleep(elapsed):
|
||||
listener.tip_height = 101 + int(elapsed.total_seconds() // 300) # a block every 300s
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
await scheduler._wait_for_draw_block(1, 101, 3, start) # needs tip 103: ~600s > 360s overall
|
||||
|
||||
async with session_factory() as session:
|
||||
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].round_id == 1
|
||||
assert (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() == []
|
||||
|
||||
|
||||
async def test_a_chain_that_stops_advancing_writes_draw_stalled_and_repeats(session_factory, monkeypatch):
|
||||
"""RND-20 / AUD-27: payload {draw_height, current_tip_height, elapsed_seconds}; one row per
|
||||
threshold for as long as the stall lasts."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = DrawListener(tip_height=101, blocks={101: "ab" * 32})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS
|
||||
|
||||
def on_sleep(elapsed):
|
||||
if elapsed >= timedelta(seconds=2 * threshold + 30):
|
||||
listener.tip_height = 103
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
await scheduler._wait_for_draw_block(1, 101, 3, start)
|
||||
|
||||
async with session_factory() as session:
|
||||
import json
|
||||
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
|
||||
payloads = [json.loads(r.payload_json) for r in rows]
|
||||
assert len(payloads) == 2
|
||||
assert all(p["draw_height"] == 101 and p["current_tip_height"] == 101 for p in payloads)
|
||||
assert payloads[0]["elapsed_seconds"] >= threshold
|
||||
|
||||
|
||||
async def test_a_quorum_that_never_agrees_writes_draw_stalled(session_factory, monkeypatch):
|
||||
"""RND-20: "oppure il quorum continua a non concordare" — the confirmations are there but
|
||||
the servers disagree for the whole threshold."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = DrawListener(tip_height=110, blocks={101: None})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS
|
||||
|
||||
def on_sleep(elapsed):
|
||||
if elapsed >= timedelta(seconds=threshold + 20):
|
||||
listener.blocks[101] = "ab" * 32
|
||||
|
||||
start = _fake_clock(monkeypatch, listener, on_sleep)
|
||||
await scheduler._wait_for_draw_block(1, 101, 3, start)
|
||||
async with session_factory() as session:
|
||||
assert len((await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()) == 1
|
||||
|
||||
|
||||
async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53
|
||||
|
||||
Reference in new issue
Block a user