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>
352 lines
14 KiB
Python
352 lines
14 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.db.base import Base
|
|
from app.db.models import Round, RoundConfig
|
|
from app.rounds.service import get_active_round, open_new_round_if_needed
|
|
|
|
ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default
|
|
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
|
|
|
|
|
@pytest.fixture
|
|
async def session_factory():
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
# B-66: no fee address means no round may open at all, which would make most of
|
|
# the assertions below pass for the wrong reason. Seeded once here so every test
|
|
# in this file runs against an instance that could actually pay a winner, and the
|
|
# ones that care about other config values edit this same single row.
|
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
|
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
|
await session.commit()
|
|
yield async_sessionmaker(engine, expire_on_commit=False)
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_opens_a_round_when_none_exists(session_factory):
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
assert round_.status == "open"
|
|
|
|
|
|
async def test_reuses_existing_open_round(session_factory):
|
|
async with session_factory() as session:
|
|
first = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
first_id = first.id
|
|
|
|
async with session_factory() as session:
|
|
second = await open_new_round_if_needed(session)
|
|
assert second.id == first_id
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["closing", "drawing", "paying_out"])
|
|
async def test_does_not_open_new_round_while_previous_is_in_progress(session_factory, status):
|
|
async with session_factory() as session:
|
|
session.add(Round(status=status))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
active = await get_active_round(session)
|
|
assert active is not None
|
|
assert active.status == status
|
|
# open_new_round_if_needed must return the in-progress round, not open a new one
|
|
returned = await open_new_round_if_needed(session)
|
|
assert returned.status == status
|
|
|
|
|
|
async def test_opens_new_round_after_previous_is_closed(session_factory):
|
|
async with session_factory() as session:
|
|
session.add(Round(status="closed"))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
assert round_.status == "open"
|
|
|
|
|
|
async def test_withholds_new_round_during_cooldown(session_factory):
|
|
async with session_factory() as session:
|
|
session.add(Round(status="closed", closed_at=datetime.now(timezone.utc)))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
assert round_ is None
|
|
|
|
|
|
async def test_opens_new_round_once_cooldown_elapses(session_factory):
|
|
stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1)
|
|
async with session_factory() as session:
|
|
session.add(Round(status="closed", closed_at=stale_close))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
assert round_.status == "open"
|
|
|
|
|
|
async def test_withholds_new_round_while_paused(session_factory):
|
|
stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1)
|
|
async with session_factory() as session:
|
|
session.add(Round(status="closed", closed_at=stale_close))
|
|
(await session.scalars(select(RoundConfig))).one().paused = True
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
assert round_ is None
|
|
|
|
|
|
async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
|
|
async with session_factory() as session:
|
|
session.add(Round(status="drawing"))
|
|
(await session.scalars(select(RoundConfig))).one().paused = True
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
returned = await open_new_round_if_needed(session)
|
|
assert returned is not None
|
|
assert returned.status == "drawing"
|
|
|
|
|
|
async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch):
|
|
"""B-09: open_new_round_if_needed was a read-then-insert with no lock, called from
|
|
both the scheduler and every place_bet, so two callers could both see "no active
|
|
round" and insert one — and a second stuck "open" row blocks every future round,
|
|
since get_active_round matches on status.
|
|
|
|
The race is forced deterministically: the round already exists and is committed,
|
|
but this caller's first look is made to miss it (exactly what the loser of the
|
|
race sees). The insert then hits ix_rounds_single_active, and the caller must
|
|
recover by using the winner's round instead of raising at its caller — a bet must
|
|
not fail because a scheduler tick beat it by a millisecond.
|
|
"""
|
|
from app.rounds import service as service_module
|
|
|
|
async with session_factory() as session:
|
|
session.add(Round(status="open"))
|
|
await session.commit()
|
|
|
|
real_get_active_round = service_module.get_active_round
|
|
calls = {"n": 0}
|
|
|
|
async def blind_first_look(session):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return None # what the loser of the race sees
|
|
return await real_get_active_round(session)
|
|
|
|
monkeypatch.setattr(service_module, "get_active_round", blind_first_look)
|
|
|
|
async with session_factory() as session:
|
|
round_ = await service_module.open_new_round_if_needed(session)
|
|
await session.commit()
|
|
|
|
assert round_ is not None # recovered, didn't raise
|
|
async with session_factory() as session:
|
|
rounds = (await session.scalars(select(Round))).all()
|
|
assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}"
|
|
assert round_.id == rounds[0].id # the winner's round, not a second one
|
|
|
|
|
|
async def test_the_database_refuses_a_second_active_round(session_factory):
|
|
"""The guarantee itself, independent of the application code path."""
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
async with session_factory() as session:
|
|
session.add(Round(status="open"))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
session.add(Round(status="drawing"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
|
|
async with session_factory() as session:
|
|
session.add(Round(status="closed"))
|
|
session.add(Round(status="closed"))
|
|
session.add(Round(status="open"))
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
assert len((await session.scalars(select(Round))).all()) == 3
|
|
|
|
|
|
# --- B-66: no round opens on an instance that could not pay its winner ------------
|
|
|
|
|
|
async def test_withholds_new_round_while_no_fee_address_is_configured(session_factory): # B-66
|
|
"""A fresh instance starts with no fee_address, and the payout pays the 30%
|
|
commission to it — so a round opened without one takes bets, confirms them, and
|
|
only then discovers it cannot be paid, wedging in "paying_out" with money already
|
|
in the pool and needing manual recovery. Every round, until an operator notices."""
|
|
async with session_factory() as session:
|
|
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
assert await open_new_round_if_needed(session) is None
|
|
|
|
async with session_factory() as session:
|
|
assert (await session.scalars(select(Round))).all() == [] # nothing opened at all
|
|
|
|
|
|
async def test_opens_a_round_as_soon_as_a_fee_address_is_set(session_factory): # B-66
|
|
async with session_factory() as session:
|
|
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
assert await open_new_round_if_needed(session) is None
|
|
|
|
async with session_factory() as session:
|
|
(await session.scalars(select(RoundConfig))).one().fee_address = _FEE_ADDRESS
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
assert round_ is not None and round_.status == "open"
|
|
|
|
|
|
async def test_a_round_in_progress_survives_the_fee_address_being_cleared(session_factory): # B-66
|
|
"""Same rule as pausing: an unmet prerequisite only stops the *next* round. The
|
|
one in progress keeps its participants and still has to be drawn and paid — and
|
|
clearing the address is exactly the mistake an operator might make mid-round."""
|
|
async with session_factory() as session:
|
|
session.add(Round(status="open"))
|
|
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
returned = await open_new_round_if_needed(session)
|
|
assert returned is not None and returned.status == "open"
|
|
|
|
|
|
def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
|
|
from app.rounds.service import rounds_can_open
|
|
|
|
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
|
|
assert rounds_can_open(RoundConfig(fee_address="")) is False
|
|
assert rounds_can_open(RoundConfig(fee_address=" ")) is False
|
|
|
|
|
|
# --- B-61: a round runs by the timing it opened with, not by the live config ------
|
|
|
|
|
|
async def test_a_new_round_snapshots_the_current_config_timing(session_factory):
|
|
async with session_factory() as session:
|
|
config = (await session.scalars(select(RoundConfig))).one()
|
|
config.round_duration_seconds = 120
|
|
config.round_cooldown_seconds = 45
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
|
|
assert round_.duration_seconds == 120
|
|
assert round_.cooldown_seconds == 45
|
|
|
|
|
|
async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory):
|
|
from app.rounds.service import round_accepts_bets
|
|
|
|
opened_at = datetime.now(timezone.utc) - timedelta(seconds=100)
|
|
still_open = Round(status="open", opened_at=opened_at, duration_seconds=600)
|
|
expired = Round(status="open", opened_at=opened_at, duration_seconds=60)
|
|
|
|
assert round_accepts_bets(still_open) is True
|
|
assert round_accepts_bets(expired) is False
|
|
|
|
|
|
async def test_cooldown_comes_from_the_round_that_closed(session_factory):
|
|
"""The gap a closing round announced is the gap that's honoured: shortening
|
|
round_cooldown_seconds afterwards must not open the next round early, nor
|
|
lengthening it hold the lottery shut."""
|
|
async with session_factory() as session:
|
|
(await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered
|
|
session.add(
|
|
Round(
|
|
status="closed",
|
|
opened_at=datetime.now(timezone.utc) - timedelta(seconds=200),
|
|
closed_at=datetime.now(timezone.utc) - timedelta(seconds=10),
|
|
cooldown_seconds=300, # what that round ran with
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
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)
|