Files
plm-lottery/tests/unit/test_state_consistency.py
T
davideandClaude Sonnet 5.5 9520c80dcf Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)
- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:08:14 +02:00

258 lines
12 KiB
Python

"""B-96: the smaller state inconsistencies — WDR-15, BET-07 (pending-covered bet), TX-16, TX-18,
TX-27, TX-29/AUD-46, DEP-15/DEP-16 and BET-02/WDR-01."""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.rounds.scheduler import CLOSING_STALL_THRESHOLD_SECONDS, RoundScheduler
from app.tx.reconcile import reconcile_once
from app.withdrawals.service import WithdrawalError, request_withdrawal
from tests.unit.fake_network import FakeNetwork
from tests.unit.test_reconcile import KnownTxClient, _seed_bet
from tests.unit.test_reconcile import session_factory as reconcile_session_factory # noqa: F401 (fixture)
from tests.unit.test_scheduler import FakeListener, _seed_closing_round
from tests.unit.test_scheduler import session_factory as scheduler_session_factory # noqa: F401 (fixture)
from tests.unit.test_withdrawals import (
BET_AMOUNT_SATS,
EXTERNAL_ADDRESS,
FakeElectrumClient,
_make_funded_user,
)
from tests.unit.test_withdrawals import session_factory as wallet_session_factory # noqa: F401 (fixture)
# --- WDR-15 and the pending-covered bet ---------------------------------------------
async def test_a_reduced_full_balance_withdrawal_is_checked_against_the_minimum(wallet_session_factory):
"""WDR-15: moving the minimum from a one-coin balance leaves a dust limit behind, which puts
the amount that actually goes out under the minimum."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 50, BET_AMOUNT_SATS)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
assert excinfo.value.code == "amount_below_minimum"
assert excinfo.value.params == {"minimum_sats": BET_AMOUNT_SATS}
assert client.broadcasted == []
async def test_a_bet_covered_only_by_unconfirmed_change_says_wait_not_insufficient(wallet_session_factory):
"""BET-07 / DEP-13: the balance on screen covers the bet, the confirmed one does not."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 51, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
await request_withdrawal(session, FakeElectrumClient(), user, EXTERNAL_ADDRESS, 1_200_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "balance_pending_confirmation"
assert excinfo.value.params["pending_sats"] > 0
async def test_a_bet_with_no_money_at_all_is_still_insufficient(wallet_session_factory):
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 52, 100_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "insufficient_balance"
# --- TX-16 / TX-18 / TX-27 -----------------------------------------------------------
async def _events(session_factory):
async with session_factory() as session:
return [e.event_type for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
async def test_a_recovered_bet_gets_the_event_its_send_would_have_written(reconcile_session_factory):
"""TX-16 / AUD-30: pending_tx_recovered alone left a recovered bet out of the audit trail."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300)
await reconcile_once(session_factory, KnownTxClient("betxid"))
assert sorted(await _events(session_factory)) == ["bet_placed", "pending_tx_recovered"]
async with session_factory() as session:
placed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "bet_placed"))).one()
assert json.loads(placed.payload_json) == {"txid": "betxid", "amount_sats": 999_000_000}
assert placed.user_id is not None and placed.round_id == 1
async def test_a_recovered_withdrawal_and_payout_get_theirs_too(reconcile_session_factory):
from app.tx.reconcile import _promote
session_factory = reconcile_session_factory
async with session_factory() as session:
withdrawal = Withdrawal(
user_id=1, external_address=EXTERNAL_ADDRESS, amount_requested_sats=5, amount_sent_sats=4, txid="w", status="building"
)
round_ = Round(status="paying_out", winner_user_id=7, winner_amount_sats=700, fee_amount_sats=300)
session.add_all([withdrawal, round_])
await session.flush()
w_row = PendingTransaction(kind="withdrawal", withdrawal_id=withdrawal.id, user_id=1, current_txid="w", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
p_row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
session.add_all([w_row, p_row])
await session.flush()
await _promote(session, w_row)
await _promote(session, p_row)
await session.commit()
rows = {e.event_type: e for e in (await session.scalars(select(AuditLog))).all()}
assert json.loads(rows["withdrawal_sent"].payload_json) == {
"txid": "w", "amount_sent_sats": 4, "external_address": EXTERNAL_ADDRESS,
}
assert json.loads(rows["payout_sent"].payload_json) == {"txid": "p", "winner_sats": 700, "commission_sats": 300}
assert rows["payout_sent"].user_id == 7
async def test_an_abandoned_payout_leaves_no_amounts_behind(reconcile_session_factory):
"""TX-18 / DAT-22."""
from app.tx.reconcile import _abandon
session_factory = reconcile_session_factory
async with session_factory() as session:
round_ = Round(
status="paying_out", payout_txid="p", winner_amount_sats=700, fee_amount_sats=300, bump_reserve_sats=50
)
session.add(round_)
await session.flush()
row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="pending")
session.add(row)
await session.flush()
await _abandon(session, row, "no server knows it")
await session.commit()
await session.refresh(round_)
assert (round_.payout_txid, round_.winner_amount_sats, round_.fee_amount_sats, round_.bump_reserve_sats) == (
None, None, None, None,
)
async def test_the_reconciler_only_pings_the_dashboards_for_rows_it_changed(reconcile_session_factory, monkeypatch):
"""TX-27: a row found present and already `pending` is examined, not changed."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=30 * 3600)
pings = []
monkeypatch.setattr(broadcaster, "publish", lambda: pings.append(1))
assert await reconcile_once(session_factory, KnownTxClient("betxid")) == 0
assert pings == []
# --- TX-29 / AUD-46 ------------------------------------------------------------------
async def _stalled_rows(session_factory):
async with session_factory() as session:
return list(
(await session.scalars(select(AuditLog).where(AuditLog.event_type == "closing_stalled").order_by(AuditLog.id))).all()
)
async def test_closing_stalled_is_written_past_the_threshold_and_repeats_every_30_minutes(scheduler_session_factory):
session_factory = scheduler_session_factory
round_id = await _seed_closing_round(session_factory, n=2)
async with session_factory() as session:
for participant in (await session.scalars(select(RoundParticipant))).all():
participant.status = "broadcast"
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
now = datetime.now(timezone.utc)
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS - 60))
assert await _stalled_rows(session_factory) == [] # not long enough yet
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 60))
(row,) = await _stalled_rows(session_factory)
payload = json.loads(row.payload_json)
assert payload["in_flight"] == 2 and len(payload["txids"]) == 2 and payload["seconds_past_deadline"] >= 1860
assert row.round_id == round_id and row.user_id is None
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 120))
assert len(await _stalled_rows(session_factory)) == 1 # the last row is the clock: not again yet
async with session_factory() as session:
last = await session.get(AuditLog, row.id)
last.created_at = last.created_at - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 1)
await session.commit()
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=2 * CLOSING_STALL_THRESHOLD_SECONDS))
assert len(await _stalled_rows(session_factory)) == 2
# --- DEP-15 / DEP-16 / BET-02 / WDR-01 ------------------------------------------------
async def test_refreshes_of_one_user_run_one_at_a_time_and_other_users_in_parallel(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
running, peak, order = {}, {}, []
async def fake_locked(user_id, scripthash):
running[user_id] = running.get(user_id, 0) + 1
peak[user_id] = max(peak.get(user_id, 0), running[user_id])
order.append((user_id, scripthash))
await asyncio.sleep(0.01)
running[user_id] -= 1
listener._refresh_user_locked = fake_locked
await asyncio.gather(
listener.refresh_user(1, "a"), listener.refresh_user(1, "b"), listener.refresh_user(2, "c")
)
assert peak == {1: 1, 2: 1}
assert [s for u, s in order if u == 1] == ["a", "b"] # in order of arrival
async def test_one_users_failed_refresh_does_not_end_the_session(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
listener._scripthash_to_user = {"s1": 1, "s2": 2}
calls = []
async def fake_refresh(user_id, scripthash):
calls.append(user_id)
if user_id == 1:
raise RuntimeError("duplicate txid/vout")
listener.refresh_user = fake_refresh
queue = asyncio.Queue()
for item in (("s1", None), ("s2", None)):
queue.put_nowait(item)
task = asyncio.create_task(listener._consume_scripthash(queue))
await asyncio.sleep(0.05)
assert calls == [1, 2] and not task.done()
task.cancel()
async def test_the_connection_is_read_after_the_user_lock_is_taken():
"""BET-02 / WDR-01: a request queued on the lock across a reconnection must use the
connection that exists when it gets the lock, not the one it saw on arrival."""
from contextlib import asynccontextmanager
from app.api.routes import bets as bets_route
listener = SimpleNamespace(client="old")
@asynccontextmanager
async def acquire(_user_id):
listener.client = None # the connection dropped while this request waited
yield
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=SimpleNamespace(acquire=acquire))))
with pytest.raises(Exception) as excinfo:
await bets_route.create_bet(request, SimpleNamespace(), SimpleNamespace(id=1), None)
assert excinfo.value.detail["code"] == "network_unavailable"