diff --git a/app/api/routes/withdrawals.py b/app/api/routes/withdrawals.py new file mode 100644 index 0000000..0cb3182 --- /dev/null +++ b/app/api/routes/withdrawals.py @@ -0,0 +1,49 @@ +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.db.models import User +from app.db.session import get_session +from app.withdrawals.service import WithdrawalError, request_withdrawal + +router = APIRouter(prefix="/withdrawals", tags=["withdrawals"]) + + +class WithdrawalRequest(BaseModel): + external_address: str + amount_sats: int + + +class WithdrawalResponse(BaseModel): + txid: str + amount_requested_sats: int + amount_sent_sats: int + status: str + + +@router.post("", response_model=WithdrawalResponse, status_code=status.HTTP_201_CREATED) +async def create_withdrawal( + body: WithdrawalRequest, + request: Request, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> WithdrawalResponse: + 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: + withdrawal = await request_withdrawal( + session, listener.client, user, body.external_address, body.amount_sats + ) + except WithdrawalError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + return WithdrawalResponse( + txid=withdrawal.txid, + amount_requested_sats=withdrawal.amount_requested_sats, + amount_sent_sats=withdrawal.amount_sent_sats, + status=withdrawal.status, + ) diff --git a/app/withdrawals/__init__.py b/app/withdrawals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/withdrawals/confirmation.py b/app/withdrawals/confirmation.py new file mode 100644 index 0000000..04d098b --- /dev/null +++ b/app/withdrawals/confirmation.py @@ -0,0 +1,18 @@ +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import PendingTransaction, Withdrawal +from app.tx.confirmation import register_handler + + +async def _on_withdrawal_confirmed(session: AsyncSession, pending: PendingTransaction) -> None: + if pending.withdrawal_id is None: + return + withdrawal = await session.get(Withdrawal, pending.withdrawal_id) + if withdrawal is not None and withdrawal.status == "broadcast": + withdrawal.status = "confirmed" + withdrawal.confirmed_at = datetime.now(timezone.utc) + + +register_handler("withdrawal", _on_withdrawal_confirmed) diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py new file mode 100644 index 0000000..8e1a76c --- /dev/null +++ b/app/withdrawals/service.py @@ -0,0 +1,86 @@ +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, User, UtxoEvent, Withdrawal +from app.electrum.client import ElectrumClient +from app.wallet.balance import recompute_balance +from app.wallet.hd import derive_user_key +from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction + + +class WithdrawalError(Exception): + pass + + +async def request_withdrawal( + session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int +) -> Withdrawal: + if amount_sats < settings.min_amount_sats: + raise WithdrawalError(f"amount below the minimum of {settings.min_amount_sats} 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) < amount_sats: + raise WithdrawalError("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=external_address, + amount_sats=amount_sats, + change_address=user.address, + fee_rate_sat_vb=settings.fee_rate_sat_vb, + ) + except InsufficientFundsError as exc: + raise WithdrawalError(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: + spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid + await recompute_balance(session, user.id) + + withdrawal = Withdrawal( + user_id=user.id, + external_address=external_address, + amount_requested_sats=amount_sats, + amount_sent_sats=built.recipient_sats, + txid=built.txid, + status="broadcast", + ) + session.add(withdrawal) + await session.flush() + session.add( + PendingTransaction( + kind="withdrawal", + withdrawal_id=withdrawal.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", + ) + ) + await write_audit_log( + session, + "withdrawal_sent", + {"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address}, + user_id=user.id, + ) + + await session.commit() + await session.refresh(withdrawal) + return withdrawal diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py new file mode 100644 index 0000000..57a6a3a --- /dev/null +++ b/tests/unit/test_withdrawals.py @@ -0,0 +1,98 @@ +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings +from app.db.base import Base +from app.db.models import PendingTransaction, User, UtxoEvent +from app.wallet.hd import derive_user_address +from app.withdrawals.service import WithdrawalError, request_withdrawal + + +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" + + +EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx" + + +@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_withdrawal_broadcasts_and_updates_balance(session_factory): + user_id = await _make_funded_user(session_factory, 0, 500_000_000) + client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, 100_000_000) + + assert client.broadcasted + assert withdrawal.status == "broadcast" + assert withdrawal.amount_sent_sats < 100_000_000 # fee deducted from the amount + + async with session_factory() as session: + user = await session.get(User, user_id) + # The spent UTXO is gone immediately; the change output isn't credited + # until it's independently observed as confirmed on-chain (same as bets) — + # so the cached balance is transiently 0 until then, not the pre-fee delta. + assert user.cached_balance_sats == 0 + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.kind == "withdrawal" + assert pending.withdrawal_id == withdrawal.id + + +async def test_withdrawal_rejects_amount_below_minimum(session_factory): + user_id = await _make_funded_user(session_factory, 1, 500_000_000) + client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError, match="minimum"): + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats - 1) + + +async def test_withdrawal_rejects_insufficient_balance(session_factory): + user_id = await _make_funded_user(session_factory, 2, 1_000_000) + client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError, match="insufficient balance"): + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats)