Files
plm-lottery/tests/unit/test_withdrawals.py
T
davideandClaude Opus 5 526a649c8b Say what bets and withdrawals actually exclude (B-70)
The flowchart's WITHDRAW node (E1) stated that a withdrawal cannot happen
together with a bet in progress. The code only serializes the two *builds*
through the per-user lock: a withdrawal is accepted while a bet is still
unconfirmed, as long as confirmed, unspent UTXOs cover it.

CLAUDE.md makes every node of the diagrams binding, so one of the two had
to move, and it is the diagram. The hazard the node was reaching for is
the two transactions picking the same UTXO, and that is already excluded
twice: app/tx/locks.py keeps the builds from overlapping, and select_utxos
skips anything already marked spent_txid. What the node forbade on top of
that is spending untouched, confirmed money — so implementing it as
written would freeze a user's whole balance for a block after every bet
and protect nothing. E1 now describes the real rule, and CLAUDE.md's
per-user-lock paragraph states it is the only exclusion between the two.

Regenerated the A4/A3 PDFs (gitignored, so not in this commit).

The regression test is behavioural, not a wording check: it funds a user
with two confirmed UTXOs, bets (taking the larger), and asserts the
withdrawal goes through on the other one with the bet still unconfirmed
and neither transaction spending the other's input. A second test keeps
the diagram from drifting back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:16:13 +02:00

358 lines
16 KiB
Python

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
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"
BET_AMOUNT_SATS = 1_000_000_000 # matches RoundConfig.bet_amount_sats' column default; also the withdrawal minimum
@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)
# B-66: a round only opens on an instance that could actually pay a winner, so
# every test that expects one needs a fee address configured — the column has no
# default on purpose (an operator must set their own).
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
await session.commit()
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, 2_000_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, BET_AMOUNT_SATS)
assert client.broadcasted
assert withdrawal.status == "broadcast"
assert withdrawal.amount_sent_sats < BET_AMOUNT_SATS # 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, 2_000_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, BET_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, BET_AMOUNT_SATS)
async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory):
"""B-37: right after a bet, cached_balance_sats is ~0 because the whole funding
UTXO was spent as input and the change hasn't confirmed yet — but the UI shows
the pending-inclusive balance (compute_pending_balance), which does cover a
withdrawal of this size. The error must say "not confirmed yet", not flatly
"insufficient balance", or it contradicts what the user is looking at."""
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
bet_client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, bet_client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input
withdraw_client = FakeElectrumClient()
with pytest.raises(WithdrawalError) as exc_info:
# Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the
# unconfirmed change (~1_999_800_000 sats), but not by the (zero)
# confirmed balance.
await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000)
assert exc_info.value.code == "balance_pending_confirmation"
assert exc_info.value.params["pending_sats"] > 0
assert not withdraw_client.broadcasted
@pytest.mark.parametrize(
"address",
[
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", # valid bech32, wrong chain
"plm1qbogus", # right HRP, broken checksum
"not-an-address",
],
)
async def test_withdrawal_rejects_non_plm_address(session_factory, address):
"""The bc1 case is the one that matters: embit parses it into a perfectly
valid witness program, so without the HRP check the withdrawal would build,
sign and broadcast on PLM, sending the funds somewhere nobody holds a key
for. It has to fail before a single UTXO is touched."""
user_id = await _make_funded_user(session_factory, 3, 2_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as exc_info:
await request_withdrawal(session, client, user, address, BET_AMOUNT_SATS)
assert exc_info.value.code == "invalid_address"
assert not client.broadcasted
async def test_withdrawal_to_own_address_is_rejected(session_factory):
"""B-17: allowed before, and it broke two things that assume the recipient and
the change are distinguishable by address — the RBF bump would shrink the
recipient output, and compute_pending_balance counted the amount twice."""
user_id = await _make_funded_user(session_factory, 8, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="own deposit address"):
await request_withdrawal(session, client, user, user.address, 1_000_000_000)
assert not client.broadcasted
async with session_factory() as session:
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The released UTXOs are spendable again and the balance changed back, so the
rollback must nudge the dashboard to refetch instead of leaving it stale until
its next poll."""
user_id = await _make_funded_user(session_factory, 10, 3_000_000_000)
class RejectingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="refused"):
await request_withdrawal(session, RejectingClient(), user, derive_user_address(98), 1_000_000_000)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(session_factory):
"""B-07/B-08: the Withdrawal row is kept (unlike a bet) so the user can see the
instruction didn't go through, but the coins must come back."""
user_id = await _make_funded_user(session_factory, 9, 3_000_000_000)
class RejectingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
async with session_factory() as session:
user = await session.get(User, user_id)
external = derive_user_address(99)
with pytest.raises(WithdrawalError, match="refused"):
await request_withdrawal(session, RejectingClient(), user, external, 1_000_000_000)
async with session_factory() as session:
withdrawal = (await session.scalars(select(Withdrawal))).one()
assert withdrawal.status == "failed"
assert withdrawal.txid is None
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000_000
# --- B-62: "withdraw everything" must not build an unbumpable transaction ---------
async def test_full_balance_withdrawal_keeps_a_bumpable_change_output(session_factory):
"""The UI's max-amount checkbox sends the whole confirmed balance, so change came
out at 0, the change output was dropped, and the tx had a single output —
bump_fee then had nothing to shrink and raised RbfError every 30s until the
reconciler abandoned the row hours later. Adding inputs is no answer here: the tx
already spends every UTXO the user has. So a dust limit stays behind instead."""
from embit.transaction import Transaction
from app.wallet.psbt_builder import DUST_LIMIT_SATS
balance = 2_000_000_000
user_id = await _make_funded_user(session_factory, 40, balance)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, balance)
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
assert len(tx.vout) == 2 # recipient + change: bumpable
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
# The user asked for everything and is told what actually went out — the row
# already distinguishes the two, since the fee comes out of the amount anyway.
assert withdrawal.amount_requested_sats == balance
fee = balance - sum(o.value for o in tx.vout)
change = min(o.value for o in tx.vout)
assert change == DUST_LIMIT_SATS
assert withdrawal.amount_sent_sats == balance - DUST_LIMIT_SATS - fee
async def test_a_bet_from_a_balance_equal_to_the_bet_is_refused(session_factory):
"""The same shape on the PLAY side, where reducing the amount isn't an option —
the bet is a fixed price. "A user's balance must never exactly equal the bet" is
a documented invariant of the PLAY phase; this is where it's enforced, with an
error that says how much more is needed rather than a bare "insufficient"."""
from app.bets.service import BetError
from app.wallet.psbt_builder import DUST_LIMIT_SATS
user_id = await _make_funded_user(session_factory, 41, BET_AMOUNT_SATS) # exactly the bet
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "balance_leaves_no_change"
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
assert not client.broadcasted
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 # refused before anything moved
async def test_a_bet_with_a_dust_limit_of_headroom_is_accepted(session_factory):
from app.wallet.psbt_builder import DUST_LIMIT_SATS
user_id = await _make_funded_user(session_factory, 42, BET_AMOUNT_SATS + DUST_LIMIT_SATS)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
async def test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed(session_factory):
"""B-70: the flowchart's WITHDRAW node used to state that a withdrawal cannot
happen together with a bet in progress. It can, and should: the hazard is the two
picking the *same* UTXO, which is already excluded twice over — the per-user lock
(app/tx/locks.py) keeps the two builds from ever being in flight at once, and
select_utxos skips anything already marked spent_txid. What is left is untouched,
confirmed money, and freezing it for a block just because a bet is in flight would
be a restriction with no safety behind it. The diagram was corrected to match."""
user_id = await _make_funded_user(session_factory, 43, 2_000_000_000)
async with session_factory() as session:
# A second confirmed UTXO the bet won't touch (select_utxos is largest-first).
session.add(
UtxoEvent(user_id=user_id, txid="ab" * 32, vout=1, amount_sats=1_500_000_000, confirmed_height=100)
)
await session.commit()
bet_client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, bet_client, user)
assert participant.status == "broadcast" # broadcast, not yet confirmed
withdraw_client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
assert withdrawal.status == "broadcast"
assert withdraw_client.broadcasted
async with session_factory() as session:
utxos = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).all()
spenders = {u.amount_sats: u.spent_txid for u in utxos}
# Each transaction took its own input; neither is spending the other's.
assert spenders[2_000_000_000] != spenders[1_500_000_000]
assert all(txid is not None for txid in spenders.values())
pending_kinds = {
p.kind for p in (await session.scalars(select(PendingTransaction))).all()
}
assert pending_kinds == {"bet", "withdrawal"}
def test_the_flowchart_no_longer_claims_bets_and_withdrawals_are_exclusive():
from pathlib import Path
diagram = (
Path(__file__).resolve().parents[2] / "flowchart" / "platform-overview.mmd"
).read_text(encoding="utf-8")
node = [line for line in diagram.splitlines() if line.strip().startswith("E1[")]
assert len(node) == 1
assert "non puo' avvenire insieme" not in node[0]
assert "saldo confermato" in node[0]