Files
plm-lottery/tests/unit/test_bets.py
T
davideandClaude Opus 5 d528c5b475 Let the system recover from a broadcast that never confirms
The code treated a broadcast as final: money moved on-chain and the DB was
updated on the assumption it would either confirm or be fee-bumped until it
did. Neither is guaranteed, and every way that assumption broke was permanent
(BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21).

Persist before broadcasting. place_bet and request_withdrawal now write their
rows in a "building" state and commit, then broadcast, then promote to
broadcast/pending in a second commit. Before, a failure or crash between the
broadcast and the commit left the coins irreversibly spent with no trace: no
participant (so no entry in the draw), no pending row (so no RBF and no
confirmation tracking), and the UTXOs not even marked spent, so the next bet
would try to double-spend them. A refused broadcast now releases the reserved
UTXOs, restores the balance, removes the participant (or marks the withdrawal
failed), audit-logs it, and answers a translatable broadcast_failed — as 502,
since the network refused it, not the caller, where it used to be an opaque 500.

Reconcile what's in flight against the chain. New PendingTransactionReconciler
(app/tx/reconcile.py, every 120s and once at startup) asks whether each
non-terminal tx exists: present -> promote, gone -> mark failed with a reason,
release the inputs, roll the domain row back, audit-log it. Grace periods differ
by state (120s for "building", 6h for "pending", so the RBF bumper gets its
attempts first). It is deliberately biased to inaction: only a server that
positively doesn't know the tx counts as absent, and a transport failure never
abandons anything, because releasing a UTXO whose tx is actually alive would
invite a double-spend. Verified against the live server, which answers "No such
mempool or blockchain transaction" for an unknown txid.

Stop keying on a value that changes. An RBF bump changes the txid, and
_on_bet_confirmed looked the participant up by bet_txid — so a bumped bet
confirmed under a txid no participant carried, the row stayed "broadcast"
forever, and the scheduler waited on it forever: the round could never close and
the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id,
withdrawal_id), and bump_fee retargets every stored txid — bet_txid,
Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the
previous one in replaced_by_txid, which was never written at all.

One bad row no longer blocks the rest. The confirmation poller's per-tx lookup
is guarded: a txid the server can't resolve used to abort the whole pass, so
nothing confirmed again until an operator intervened. It also selects plain
columns instead of hydrating entities that outlive their session.

Tests: 6 reconciler cases including "a broken connection must not release coins";
the bet-ordering test probes committed state from an independent session during
the broadcast, and caught a real mistake in the first draft of this change (the
_pending_transaction helper still hardcoded status="pending", so rows were born
already-broadcast and would have got the 6-hour grace instead of 120s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:31:24 +02:00

205 lines
8.4 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.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.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address
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)
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
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_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:
session.add(RoundConfig(fee_address="", 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 RuntimeError("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)
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_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)
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"]