2026-07-27 00:31:24 +02:00
|
|
|
"""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()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 09:39:38 +02:00
|
|
|
async def _seed_bet(
|
|
|
|
|
session_factory,
|
|
|
|
|
*,
|
|
|
|
|
pending_status: str,
|
|
|
|
|
participant_status: str,
|
|
|
|
|
age_seconds: int,
|
|
|
|
|
last_broadcast_age_seconds: int | None = None,
|
|
|
|
|
):
|
2026-07-27 00:31:24 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-07-27 09:39:38 +02:00
|
|
|
# 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
|
2026-07-27 00:31:24 +02:00
|
|
|
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),
|
2026-07-27 09:39:38 +02:00
|
|
|
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age),
|
2026-07-27 00:31:24 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 09:39:38 +02:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
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
|