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>
451 lines
19 KiB
Python
451 lines
19 KiB
Python
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
|
|
|
|
from app.bets.service import BetError, place_bet
|
|
from app.config import settings
|
|
from app.db.base import Base
|
|
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
|
|
from app.rounds.events import broadcaster
|
|
from app.rounds.service import open_new_round_if_needed
|
|
from app.wallet.hd import derive_user_address
|
|
from app.wallet.psbt_builder import MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS
|
|
from tests.unit.fake_network import FakeNetwork, rpc_error
|
|
|
|
|
|
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
|
|
|
|
|
class FakeElectrumClient:
|
|
def __init__(self):
|
|
self.broadcasted: list[str] = []
|
|
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
|
self.broadcasted.append(raw_tx_hex)
|
|
return "fake-network-txid"
|
|
|
|
|
|
@pytest.fixture
|
|
async def session_factory(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
|
monkeypatch.setattr(settings, "xprv_encryption_key", __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode())
|
|
from app.wallet import hd
|
|
|
|
hd._account_key = None
|
|
hd.generate_master_key()
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
# B-66: a round only opens on an instance that could actually pay a winner, so
|
|
# every test that expects one needs a fee address configured — the column has no
|
|
# default on purpose (an operator must set their own).
|
|
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()
|
|
hd._account_key = None
|
|
|
|
|
|
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
|
|
async with session_factory() as session:
|
|
address = derive_user_address(index)
|
|
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
|
|
session.add(user)
|
|
await session.commit()
|
|
session.add(
|
|
UtxoEvent(
|
|
user_id=user.id,
|
|
txid=f"{index:02x}" * 32,
|
|
vout=0,
|
|
amount_sats=funded_sats,
|
|
confirmed_height=100,
|
|
)
|
|
)
|
|
await session.commit()
|
|
return user.id
|
|
|
|
|
|
async def test_place_bet_broadcasts_and_records_participant(session_factory):
|
|
user_id = await _make_funded_user(session_factory, 0, 1_500_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
participant = await place_bet(session, client, user)
|
|
|
|
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()
|
|
assert utxo.spent_txid == participant.bet_txid
|
|
|
|
pending = (await session.scalars(select(PendingTransaction))).one()
|
|
assert pending.kind == "bet"
|
|
|
|
audit_events = (await session.scalars(select(AuditLog))).all()
|
|
assert any(e.event_type == "bet_placed" for e in audit_events)
|
|
assert pending.current_txid == participant.bet_txid
|
|
|
|
|
|
async def test_place_bet_rejects_insufficient_balance(session_factory):
|
|
user_id = await _make_funded_user(session_factory, 1, 1_000_000) # below bet_amount_sats
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError, match="insufficient balance"):
|
|
await place_bet(session, client, user)
|
|
|
|
|
|
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
|
|
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
|
|
# only add up to 7.5 PLM — so the build must fail with its own code, not with
|
|
# the "you have no funds" one, and must carry the cap for the translation.
|
|
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
|
|
async with session_factory() as session:
|
|
for i in range(99):
|
|
session.add(
|
|
UtxoEvent(
|
|
user_id=user_id,
|
|
txid=f"{i:064x}",
|
|
vout=0,
|
|
amount_sats=15_000_000,
|
|
confirmed_height=100,
|
|
)
|
|
)
|
|
await session.commit()
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError) as excinfo:
|
|
await place_bet(session, client, user)
|
|
|
|
assert excinfo.value.code == "too_many_inputs"
|
|
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
|
|
assert not client.broadcasted
|
|
|
|
|
|
async def _fill_round_with_participants(session_factory, round_id: int, count: int) -> None:
|
|
"""Participant rows only, no real bets: what the cap counts is rows, and building
|
|
`count` genuine transactions would just make the test slow without exercising
|
|
anything the other tests don't already cover."""
|
|
async with session_factory() as session:
|
|
for i in range(count):
|
|
session.add(
|
|
RoundParticipant(
|
|
round_id=round_id,
|
|
user_id=10_000 + i, # placeholder ids; the cap check never joins users
|
|
bet_amount_sats=1_000_000_000,
|
|
bet_txid=f"{i:064x}",
|
|
status="confirmed",
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def test_place_bet_rejects_the_bet_past_the_participant_cap(session_factory): # B-52
|
|
"""The payout has to spend one pool UTXO per bet, so a round is only ever allowed
|
|
to grow to what a single payout transaction can drain. Enforced here, before the
|
|
player's money moves — not discovered at payout time, when the bets are already in
|
|
the pool and the round can no longer be paid at all."""
|
|
user_id = await _make_funded_user(session_factory, 30, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
round_id = round_.id
|
|
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError) as excinfo:
|
|
await place_bet(session, client, user)
|
|
|
|
assert excinfo.value.code == "round_full"
|
|
assert excinfo.value.params == {"max_participants": MAX_PARTICIPANTS_PER_ROUND}
|
|
assert not client.broadcasted
|
|
|
|
# Refused cleanly: no participant row, and the user's UTXO is still spendable.
|
|
async with session_factory() as session:
|
|
assert await session.scalar(
|
|
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_id)
|
|
) == MAX_PARTICIPANTS_PER_ROUND
|
|
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
|
assert utxo.spent_txid is None
|
|
|
|
|
|
async def test_place_bet_still_accepts_the_last_slot_under_the_cap(session_factory): # B-52
|
|
user_id = await _make_funded_user(session_factory, 31, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
round_id = round_.id
|
|
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND - 1)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
participant = await place_bet(session, client, user)
|
|
|
|
assert participant.status == "broadcast"
|
|
assert client.broadcasted
|
|
|
|
|
|
async def test_place_bet_rejects_second_bet_same_round(session_factory):
|
|
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
await place_bet(session, client, user)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError, match="already"):
|
|
await place_bet(session, client, user)
|
|
|
|
async with session_factory() as session:
|
|
participants = (await session.scalars(select(RoundParticipant))).all()
|
|
assert len(participants) == 1
|
|
|
|
|
|
async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_factory):
|
|
"""The scheduler only flips status "open" -> "closing" on its next tick (up
|
|
to a few seconds late) — place_bet must independently refuse bets once the
|
|
round's own deadline has passed, so no new player can sneak in during that
|
|
gap (see rounds/service.round_accepts_bets)."""
|
|
user_id = await _make_funded_user(session_factory, 3, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
config = (await session.scalars(select(RoundConfig))).one() # seeded by the fixture
|
|
config.round_duration_seconds = 60
|
|
round_ = await open_new_round_if_needed(session)
|
|
round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61)
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError, match="closing"):
|
|
await place_bet(session, client, user)
|
|
|
|
async with session_factory() as session:
|
|
participants = (await session.scalars(select(RoundParticipant))).all()
|
|
assert len(participants) == 0
|
|
round_ = (await session.scalars(select(Round))).one()
|
|
assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware
|
|
|
|
|
|
class RejectingElectrumClient:
|
|
"""A node that refuses the transaction — fee too low, dust output, mempool
|
|
conflict, or simply an unreachable server."""
|
|
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
|
raise rpc_error("min relay fee not met")
|
|
|
|
|
|
async def test_failed_broadcast_leaves_nothing_behind(session_factory):
|
|
"""B-07/B-08: the broadcast used to happen before anything was written, so a
|
|
rejection left the UTXOs marked spent with no rows to explain it, and the caller
|
|
got an opaque HTTP 500. Now it's a translatable error and a full rollback."""
|
|
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError, match="refused"):
|
|
await place_bet(session, RejectingElectrumClient(), user, network=FakeNetwork(known=False))
|
|
|
|
async with session_factory() as session:
|
|
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
|
assert utxo.spent_txid is None # released, so the user can bet again
|
|
assert (await session.scalars(select(RoundParticipant))).all() == []
|
|
assert (await session.scalars(select(PendingTransaction))).all() == []
|
|
user = await session.get(User, user_id)
|
|
assert user.cached_balance_sats == 3_000_000_000
|
|
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
|
assert "bet_broadcast_failed" in events
|
|
assert "bet_placed" not in events
|
|
|
|
|
|
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
|
|
"""The rollback moves as much state as the successful path does, so it must ping
|
|
the dashboards the same way — otherwise the phantom bet stays on screen until the
|
|
next poll."""
|
|
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
|
|
async with session_factory() as session:
|
|
# Open the round up front: place_bet would otherwise open it itself, and that
|
|
# publish() would satisfy the assertion below whether or not the rollback ever
|
|
# published one of its own.
|
|
await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
|
|
queue = broadcaster.subscribe()
|
|
try:
|
|
while not queue.empty():
|
|
queue.get_nowait()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
with pytest.raises(BetError, match="refused"):
|
|
await place_bet(session, RejectingElectrumClient(), user, network=FakeNetwork(known=False))
|
|
|
|
assert not queue.empty()
|
|
finally:
|
|
broadcaster.unsubscribe(queue)
|
|
|
|
|
|
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
|
|
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
try:
|
|
await place_bet(session, RejectingElectrumClient(), user, network=FakeNetwork(known=False))
|
|
assert False, "expected BetError"
|
|
except BetError as exc:
|
|
assert exc.code == "broadcast_failed"
|
|
|
|
|
|
async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
|
|
"""The ordering guarantee behind B-08: by the time the network call happens, the
|
|
rows already exist, so a crash there is recoverable rather than silent."""
|
|
user_id = await _make_funded_user(session_factory, 6, 3_000_000_000)
|
|
seen: dict[str, object] = {}
|
|
|
|
class ObservingClient:
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
|
# Read committed state from an independent session, mid-broadcast.
|
|
async with session_factory() as probe:
|
|
seen["pending"] = [
|
|
(p.kind, p.status) for p in (await probe.scalars(select(PendingTransaction))).all()
|
|
]
|
|
seen["participants"] = [
|
|
(p.status) for p in (await probe.scalars(select(RoundParticipant))).all()
|
|
]
|
|
return "network-txid"
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
await place_bet(session, ObservingClient(), user)
|
|
|
|
assert seen["pending"] == [("bet", "building")]
|
|
assert seen["participants"] == ["building"]
|
|
|
|
|
|
# --- B-53: a bet must never pay into the pool of a round it was left out of ------
|
|
|
|
|
|
async def _assert_bet_left_no_trace(session_factory, user_id: int, balance_before: int) -> None:
|
|
async with session_factory() as session:
|
|
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
|
assert utxo.spent_txid is None # nothing reserved, so the user can bet next round
|
|
assert (await session.scalars(select(RoundParticipant))).all() == []
|
|
assert (await session.scalars(select(PendingTransaction))).all() == []
|
|
user = await session.get(User, user_id)
|
|
assert user.cached_balance_sats == balance_before # the rollback undid the recompute too
|
|
|
|
|
|
async def test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit(
|
|
session_factory, monkeypatch
|
|
): # B-53
|
|
"""The scheduler flips "open" -> "closing" in a transaction of its own and only
|
|
then counts in-flight bets. A bet whose deadline check passed just before that
|
|
flip must not be able to commit its participant row afterwards: it would be
|
|
excluded from the draw (only "confirmed" participants are drawn) while its sats
|
|
still landed in the pool address — credited to no round, with no refund path.
|
|
|
|
round_accepts_bets is forced to pass so the refusal can only come from the
|
|
compare-and-set on the round row, which is the part that survives the race the
|
|
wall-clock check cannot see."""
|
|
user_id = await _make_funded_user(session_factory, 40, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
round_id = round_.id
|
|
|
|
monkeypatch.setattr("app.bets.service.round_accepts_bets", lambda *args, **kwargs: True)
|
|
|
|
async with session_factory() as session:
|
|
# What the scheduler's own tick would have committed a moment earlier.
|
|
(await session.get(Round, round_id)).status = "closing"
|
|
await session.commit()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
balance_before = user.cached_balance_sats
|
|
with pytest.raises(BetError) as excinfo:
|
|
await place_bet(session, client, user)
|
|
|
|
assert excinfo.value.code == "round_closing"
|
|
assert not client.broadcasted # refused before any money moved
|
|
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
|
|
|
|
|
async def test_place_bet_rechecks_the_deadline_after_building_the_transaction(
|
|
session_factory, monkeypatch
|
|
): # B-53
|
|
"""The first deadline check happens before the UTXO scan and the signing, so a
|
|
slow build could carry a bet past the round's deadline. It is re-checked against
|
|
the clock as it is at commit time."""
|
|
user_id = await _make_funded_user(session_factory, 41, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
|
|
checks: list[bool] = []
|
|
|
|
def _accepts_then_expires(*args, **kwargs) -> bool:
|
|
checks.append(True)
|
|
return len(checks) == 1 # open when the bet arrived, expired by the time it was built
|
|
|
|
monkeypatch.setattr("app.bets.service.round_accepts_bets", _accepts_then_expires)
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
balance_before = user.cached_balance_sats
|
|
with pytest.raises(BetError) as excinfo:
|
|
await place_bet(session, client, user)
|
|
|
|
assert len(checks) == 2 # the re-check really ran
|
|
assert excinfo.value.code == "round_closing"
|
|
assert not client.broadcasted
|
|
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
|
|
|
|
|
async def test_place_bet_still_succeeds_while_the_round_is_open(session_factory): # B-53
|
|
"""The guard must not refuse the normal path: an open, in-time round still takes
|
|
bets, and the round's status is left untouched by the compare-and-set."""
|
|
user_id = await _make_funded_user(session_factory, 42, 3_000_000_000)
|
|
client = FakeElectrumClient()
|
|
|
|
async with session_factory() as session:
|
|
user = await session.get(User, user_id)
|
|
participant = await place_bet(session, client, user)
|
|
|
|
assert participant.status == "broadcast"
|
|
async with session_factory() as session:
|
|
round_ = (await session.scalars(select(Round))).one()
|
|
assert round_.status == "open"
|