"""Regression tests for B-04 (and the "building" half of B-08): a transaction that never made it onto the chain must give the coins back instead of freezing them.""" import pytest from embit import script from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal from app.tx.reconcile import reconcile_once class UnknownTxClient: """A server that doesn't know any of the txids it's asked about.""" async def get_transaction(self, txid: str, verbose: bool = False): raise RuntimeError(f"missing transaction {txid}") class KnownTxClient: async def get_transaction(self, txid: str, verbose: bool = False): return {"txid": txid, "confirmations": 0} class BrokenClient: """A transport failure — says nothing about whether the tx exists.""" async def get_transaction(self, txid: str, verbose: bool = False): raise ConnectionResetError("connection reset") @pytest.fixture async def session_factory(): 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() # A real (unsigned) transaction spending one input, built rather than hand-written # so it round-trips through Transaction.parse — that parse is how the reconciler # discovers which UTXOs to release, so a fixture the parser rejects would test # nothing. _TX_INPUT_TXID = "11" * 32 _RAW_TX = ( Transaction( vin=[TransactionInput(bytes.fromhex(_TX_INPUT_TXID), 0)], vout=[ TransactionOutput( 999_000_000, script.Script.from_address("plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") ) ], ) .serialize() .hex() ) async def _seed_bet( session_factory, *, pending_status: str, participant_status: str, age_seconds: int, last_broadcast_age_seconds: int | None = None, ): from datetime import datetime, timedelta, timezone async with session_factory() as session: user = User(username="u", password_hash="x", derivation_index=0, address="plm1qtest") session.add(user) await session.flush() session.add( UtxoEvent( user_id=user.id, txid=_TX_INPUT_TXID, vout=0, amount_sats=1_000_000_000, confirmed_height=10, spent_txid="betxid", ) ) session.add( RoundParticipant( round_id=1, user_id=user.id, bet_amount_sats=999_000_000, bet_txid="betxid", status=participant_status, ) ) # last_broadcast_age_seconds defaults to age_seconds (never bumped): the two # timestamps only diverge in the B-27 regression test below, which simulates # a tx that's been bumped recently but first appeared long ago. last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds session.add( PendingTransaction( kind="bet", round_id=1, user_id=user.id, current_txid="betxid", fee_rate_sat_vb=1, raw_tx_hex=_RAW_TX, status=pending_status, broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds), last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age), ) ) await session.commit() return user.id async def test_abandons_a_building_bet_and_gives_the_coins_back(session_factory): """The crash-mid-broadcast case: the tx isn't on the chain, so the UTXO must be released, the participant removed (they never entered the round) and the balance restored. Before this existed, spent_txid stayed set forever and the user simply lost the coins.""" user_id = await _seed_bet( session_factory, pending_status="building", participant_status="building", age_seconds=300 ) resolved = await reconcile_once(session_factory, UnknownTxClient()) assert resolved == 1 async with session_factory() as session: utxo = (await session.scalars(select(UtxoEvent))).one() assert utxo.spent_txid is None # spendable again assert (await session.scalars(select(RoundParticipant))).all() == [] row = (await session.scalars(select(PendingTransaction))).one() assert row.status == "failed" assert row.failure_reason user = await session.get(User, user_id) assert user.cached_balance_sats == 1_000_000_000 events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] assert "pending_tx_abandoned" in events async def test_promotes_a_building_row_whose_tx_did_reach_the_chain(session_factory): """We died after the broadcast, not before: the tx is real, so the rows must be finished rather than rolled back.""" await _seed_bet( session_factory, pending_status="building", participant_status="building", age_seconds=300 ) resolved = await reconcile_once(session_factory, KnownTxClient()) assert resolved == 1 async with session_factory() as session: row = (await session.scalars(select(PendingTransaction))).one() assert row.status == "pending" participant = (await session.scalars(select(RoundParticipant))).one() assert participant.status == "broadcast" utxo = (await session.scalars(select(UtxoEvent))).one() assert utxo.spent_txid == "betxid" # still legitimately spent async def test_leaves_a_young_building_row_alone(session_factory): """A row written seconds ago may just be a broadcast still in flight.""" await _seed_bet( session_factory, pending_status="building", participant_status="building", age_seconds=5 ) assert await reconcile_once(session_factory, UnknownTxClient()) == 0 async with session_factory() as session: assert (await session.scalars(select(PendingTransaction))).one().status == "building" async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory): """A broadcast tx gets a wide grace window — absence from one server's mempool is not proof of death, and the RBF bumper should get its attempts first.""" await _seed_bet( session_factory, pending_status="pending", participant_status="broadcast", age_seconds=3600 ) assert await reconcile_once(session_factory, UnknownTxClient()) == 0 async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory): """B-27 regression: before last_broadcast_at existed, bump_fee overwrote broadcast_at on every bump, which is the same field the abandon grace period is measured from — so a tx first seen long ago but bumped minutes ago (exactly what a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was never abandoned. The reconciler must still abandon it based on when it *first* appeared, ignoring how recently it was last bumped.""" await _seed_bet( session_factory, pending_status="pending", participant_status="broadcast", age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window last_broadcast_age_seconds=60, # bumped a minute ago ) assert await reconcile_once(session_factory, UnknownTxClient()) == 1 async with session_factory() as session: assert (await session.scalars(select(PendingTransaction))).one().status == "failed" async def test_transport_failure_never_abandons_anything(session_factory): """A dead connection says nothing about the transaction. Treating it as "gone" would release coins for transactions that are perfectly alive.""" await _seed_bet( session_factory, pending_status="building", participant_status="building", age_seconds=300 ) assert await reconcile_once(session_factory, BrokenClient()) == 0 async with session_factory() as session: assert (await session.scalars(select(PendingTransaction))).one().status == "building" assert (await session.scalars(select(UtxoEvent))).one().spent_txid == "betxid" async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): """Unlike a bet, a withdrawal is an instruction the user gave: the row stays so they can see it didn't go through.""" from datetime import datetime, timedelta, timezone async with session_factory() as session: user = User(username="w", password_hash="x", derivation_index=1, address="plm1qtest2") session.add(user) await session.flush() session.add( UtxoEvent( user_id=user.id, txid=_TX_INPUT_TXID, vout=0, amount_sats=500_000_000, confirmed_height=10, spent_txid="wdtxid", ) ) withdrawal = Withdrawal( user_id=user.id, external_address="plm1qexternal", amount_requested_sats=400_000_000, amount_sent_sats=399_000_000, txid="wdtxid", status="broadcast", ) session.add(withdrawal) await session.flush() session.add( PendingTransaction( kind="withdrawal", withdrawal_id=withdrawal.id, user_id=user.id, current_txid="wdtxid", fee_rate_sat_vb=1, raw_tx_hex=_RAW_TX, status="pending", broadcast_at=datetime.now(timezone.utc) - timedelta(days=1), ) ) await session.commit() assert await reconcile_once(session_factory, UnknownTxClient()) == 1 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))).one().spent_txid is None