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>
This commit is contained in:
@@ -301,3 +301,57 @@ async def test_a_bet_with_a_dust_limit_of_headroom_is_accepted(session_factory):
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user