Files
plm-lottery/tests/unit/test_reconcile.py
T
davideandClaude Sonnet 5.5 a4b160e240 Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.

- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
  the history of the row's address and looks for every txid of the row. Present on any
  server -> promoted (adopting the version found). Absent is only "no server of the
  quorum knows it" and needs the active server plus the quorum minimum of the others to
  have answered; even then nothing is released without proof:
  (a) an input spent in a block by a transaction that is none of the row's versions,
      agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
  (b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
      refused by all of them, explicitly, for missing/spent inputs.
  Accepted or "already known" by any server means alive; a fee refusal is not proof
  (`building` -> `pending` for the bumper, `pending` untouched); too few answers or
  anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
  a 14-day watch: a failed row that a majority of the other servers (at least the quorum
  minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
  pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
  The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
  from POST /admin/pause (which accepts an optional `note` and never overwrites an
  automatic reason), cleared by resume, which logs the reason it lifted; exposed on
  /admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
  system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
  but never abandon.

Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:07:09 +02:00

348 lines
14 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.
Also covers B-41: existence/reconciliation checks go through
blockchain.scripthash.get_history rather than a verbose blockchain.transaction.get
reply, so the fake clients below implement get_history.
"""
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.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.tx.reconcile import reconcile_once
from tests.unit.fake_network import ScriptedNetwork
class UnknownTxClient:
"""A server whose history for any address never includes our txid."""
async def get_history(self, scripthash: str) -> list[dict]:
return []
class KnownTxClient:
"""A server whose history for the address includes our txid — mined or
still in the mempool doesn't matter for existence, only for confirmation
(which is tx/confirmation.py's concern, not reconcile.py's)."""
def __init__(self, txid: str = "betxid"):
self._txid = txid
async def get_history(self, scripthash: str) -> list[dict]:
return [{"tx_hash": self._txid, "height": 100}]
class BrokenClient:
"""A transport failure — says nothing about whether the tx exists."""
async def get_history(self, scripthash: str) -> list[dict]:
raise ConnectionResetError("connection reset")
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
# own_address_for (B-41) derives each row's address via the HD wallet rather
# than trusting the DB's address column, so reconcile_once now needs a real
# master key set up — same bootstrap test_broadcast.py uses.
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)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
# 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,
derivation_index: int = 0,
):
from datetime import datetime, timedelta, timezone
from app.wallet.hd import derive_user_address
async with session_factory() as session:
user = User(
username="u",
password_hash="x",
derivation_index=derivation_index,
address=derive_user_address(derivation_index),
)
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(), ScriptedNetwork.proof_of_absence())
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("betxid"))
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(), ScriptedNetwork.proof_of_absence()) == 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
from app.wallet.hd import derive_user_address
async with session_factory() as session:
user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1))
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(), ScriptedNetwork.proof_of_absence()) == 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
# --- B-41: existence checks now use get_history and share it across candidates
# sharing the same address, instead of a per-tx verbose blockchain.transaction.get. --
async def test_reconcile_once_caches_history_per_scripthash(session_factory):
"""Two payout PendingTransaction rows of one round share that round's pool address (B-80) —
fetching its history twice in one pass would be wasteful and, at scale
across many candidates on one address, needlessly slow the whole tick."""
from datetime import datetime, timedelta, timezone
from app.wallet.hd import derive_pool_address
async with session_factory() as session:
old = datetime.now(timezone.utc) - timedelta(hours=7)
session.add(Round(id=1, status="paying_out", pool_address=derive_pool_address(1)))
session.add(
PendingTransaction(
kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
)
)
session.add(
PendingTransaction(
kind="payout", round_id=1, current_txid="payout-b", fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
)
)
await session.commit()
call_count = {"n": 0}
class CountingClient:
async def get_history(self, scripthash: str) -> list[dict]:
call_count["n"] += 1
return [{"tx_hash": "payout-a", "height": 100}, {"tx_hash": "payout-b", "height": 100}]
resolved = await reconcile_once(session_factory, CountingClient())
assert resolved == 0 # both exist — nothing to abandon or promote (already "pending")
assert call_count["n"] == 1 # one call covered both rows sharing the pool address