From 492fc29eca279b5408464b8e9074bb1bfa394620 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 10:26:11 +0200 Subject: [PATCH] Add bet flow Places the fixed-cost bet into the current round: builds and broadcasts the user->pool PSBT with change back to the user's own address, enforces at most one active bet per user, and registers the confirmation handler that marks a bet confirmed and adds the participant to the round. Co-Authored-By: Claude Sonnet 5 --- app/api/routes/bets.py | 41 +++++++++++++++ app/bets/__init__.py | 0 app/bets/confirmation.py | 19 +++++++ app/bets/service.py | 104 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_bets.py | 106 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 app/api/routes/bets.py create mode 100644 app/bets/__init__.py create mode 100644 app/bets/confirmation.py create mode 100644 app/bets/service.py create mode 100644 tests/unit/test_bets.py diff --git a/app/api/routes/bets.py b/app/api/routes/bets.py new file mode 100644 index 0000000..4d34b8f --- /dev/null +++ b/app/api/routes/bets.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user +from app.bets.service import BetError, place_bet +from app.db.models import User +from app.db.session import get_session + +router = APIRouter(prefix="/bets", tags=["bets"]) + + +class BetResponse(BaseModel): + round_id: int + bet_txid: str + bet_amount_sats: int + status: str + + +@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED) +async def create_bet( + request: Request, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> BetResponse: + listener = request.app.state.electrum_listener + if listener.client is None: + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly") + + async with request.app.state.user_locks.acquire(user.id): + try: + participant = await place_bet(session, listener.client, user) + except BetError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + return BetResponse( + round_id=participant.round_id, + bet_txid=participant.bet_txid, + bet_amount_sats=participant.bet_amount_sats, + status=participant.status, + ) diff --git a/app/bets/__init__.py b/app/bets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bets/confirmation.py b/app/bets/confirmation.py new file mode 100644 index 0000000..4162d9c --- /dev/null +++ b/app/bets/confirmation.py @@ -0,0 +1,19 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import PendingTransaction, RoundParticipant +from app.tx.confirmation import register_handler + + +async def _on_bet_confirmed(session: AsyncSession, pending: PendingTransaction) -> None: + participant = await session.scalar( + select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid) + ) + if participant is not None and participant.status == "broadcast": + participant.status = "confirmed" + participant.confirmed_at = datetime.now(timezone.utc) + + +register_handler("bet", _on_bet_confirmed) diff --git a/app/bets/service.py b/app/bets/service.py new file mode 100644 index 0000000..aeb22f2 --- /dev/null +++ b/app/bets/service.py @@ -0,0 +1,104 @@ +from datetime import datetime, timezone + +from embit import script +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.audit.log import write_audit_log +from app.config import settings +from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent +from app.electrum.client import ElectrumClient +from app.rounds.config import get_round_config +from app.rounds.service import open_new_round_if_needed +from app.wallet.balance import recompute_balance +from app.wallet.hd import derive_pool_address, derive_user_key +from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction + + +class BetError(Exception): + pass + + +async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant: + round_ = await open_new_round_if_needed(session) + if round_.status != "open": + raise BetError("the current round is closing, please try again shortly") + + already_playing = await session.scalar( + select(RoundParticipant).where( + RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id + ) + ) + if already_playing is not None: + raise BetError("you already have an active bet in the current round") + + config = await get_round_config(session) + bet_amount = config.bet_amount_sats + + unspent = ( + await session.scalars( + select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) + ) + ).all() + if sum(u.amount_sats for u in unspent) < bet_amount: + raise BetError("insufficient balance") + + user_key = derive_user_key(user.derivation_index) + from_script = script.p2wpkh(user_key.to_public()) + utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent] + + try: + built = build_signed_transaction( + signing_key=user_key, + from_script=from_script, + utxos=utxos, + to_address=derive_pool_address(), + amount_sats=bet_amount, + change_address=user.address, + fee_rate_sat_vb=settings.fee_rate_sat_vb, + ) + except InsufficientFundsError as exc: + raise BetError(str(exc)) from exc + + await client.broadcast(built.raw_hex) + + spent_by_key = {(u.txid, u.vout): u for u in unspent} + for spent in built.spent_utxos: + row = spent_by_key[(spent.txid, spent.vout)] + row.spent_txid = built.txid + await recompute_balance(session, user.id) + + broadcast_at = datetime.now(timezone.utc) + participant = RoundParticipant( + round_id=round_.id, + user_id=user.id, + bet_amount_sats=built.recipient_sats, + bet_txid=built.txid, + broadcast_at=broadcast_at, + status="broadcast", + ) + session.add(participant) + session.add(_pending_transaction(round_.id, user.id, built)) + await write_audit_log( + session, + "bet_placed", + {"txid": built.txid, "amount_sats": built.recipient_sats}, + user_id=user.id, + round_id=round_.id, + ) + + await session.commit() + await session.refresh(participant) + return participant + + +def _pending_transaction(round_id: int, user_id: int, built: BuiltTransaction) -> PendingTransaction: + return PendingTransaction( + kind="bet", + round_id=round_id, + user_id=user_id, + current_txid=built.txid, + fee_rate_sat_vb=settings.fee_rate_sat_vb, + raw_tx_hex=built.raw_hex, + status="pending", + ) diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py new file mode 100644 index 0000000..8e1d2bb --- /dev/null +++ b/tests/unit/test_bets.py @@ -0,0 +1,106 @@ +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, RoundParticipant, User, UtxoEvent +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