- 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>
258 lines
12 KiB
Python
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"
|