The code treated a broadcast as final: money moved on-chain and the DB was updated on the assumption it would either confirm or be fee-bumped until it did. Neither is guaranteed, and every way that assumption broke was permanent (BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21). Persist before broadcasting. place_bet and request_withdrawal now write their rows in a "building" state and commit, then broadcast, then promote to broadcast/pending in a second commit. Before, a failure or crash between the broadcast and the commit left the coins irreversibly spent with no trace: no participant (so no entry in the draw), no pending row (so no RBF and no confirmation tracking), and the UTXOs not even marked spent, so the next bet would try to double-spend them. A refused broadcast now releases the reserved UTXOs, restores the balance, removes the participant (or marks the withdrawal failed), audit-logs it, and answers a translatable broadcast_failed — as 502, since the network refused it, not the caller, where it used to be an opaque 500. Reconcile what's in flight against the chain. New PendingTransactionReconciler (app/tx/reconcile.py, every 120s and once at startup) asks whether each non-terminal tx exists: present -> promote, gone -> mark failed with a reason, release the inputs, roll the domain row back, audit-log it. Grace periods differ by state (120s for "building", 6h for "pending", so the RBF bumper gets its attempts first). It is deliberately biased to inaction: only a server that positively doesn't know the tx counts as absent, and a transport failure never abandons anything, because releasing a UTXO whose tx is actually alive would invite a double-spend. Verified against the live server, which answers "No such mempool or blockchain transaction" for an unknown txid. Stop keying on a value that changes. An RBF bump changes the txid, and _on_bet_confirmed looked the participant up by bet_txid — so a bumped bet confirmed under a txid no participant carried, the row stayed "broadcast" forever, and the scheduler waited on it forever: the round could never close and the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id, withdrawal_id), and bump_fee retargets every stored txid — bet_txid, Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the previous one in replaced_by_txid, which was never written at all. One bad row no longer blocks the rest. The confirmation poller's per-tx lookup is guarded: a txid the server can't resolve used to abort the whole pass, so nothing confirmed again until an operator intervened. It also selects plain columns instead of hydrating entities that outlive their session. Tests: 6 reconciler cases including "a broken connection must not release coins"; the bet-ordering test probes committed state from an independent session during the broadcast, and caught a real mistake in the first draft of this change (the _pending_transaction helper still hardcoded status="pending", so rows were born already-broadcast and would have got the 6-hour grace instead of 120s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
8.7 KiB
Python
234 lines
8.7 KiB
Python
"""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):
|
|
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,
|
|
)
|
|
)
|
|
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),
|
|
)
|
|
)
|
|
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_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
|