Files
plm-lottery/tests/unit/test_sqlite_transactions.py
T
davideandClaude Sonnet 5.5 a3c51719bd Confirm transactions by merkle proof and reinstate coins only on a quorum (B-83, TX-06, TX-22, TX-23, ELC-25, ELC-26, DEP-02, DEP-08, AUD-18, ADR-07)
poll_once marked a transaction confirmed on one server's `height > 0`, and
reinstate_reappeared_utxos restored a coin written off as spent outside the platform on
the active server's word alone, so a hostile active server could report a
never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin
back into a balance.

- electrum/merkle.py: pure merkle-branch verification against a block header.
- ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for
  blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum
  must return a proof that recomputes to the merkle root of that block's header (block
  height and position checked too). The header is the tip the listener holds, or for an
  older block the one a majority of the quorum agrees on and that meets its own target;
  a height above our tip cannot be proven. Quorum rules are _corroborate_majority's.
- poll_once takes the listener as `network`: the active server's history only nominates
  a candidate (txid, height); the row confirms only when the proof holds, and stays
  pending otherwise. With no network nothing confirms. ConfirmationPoller wires it.
- The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed
  (txid, height) to the same proof instead of counting histories.
- refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only;
  same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same
  quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated,
  rechecking the mark under the lock. Nothing is committed in the read phase any more.
- Broadcast fan-out, the third part of the finding, shipped with B-76.

Tests changed because the spec changed: every poll_once call now passes a network that
proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row
"quorum does not confirm" test now asserts the claim goes to the proof.

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

889 lines
37 KiB
Python

"""B-88 (DAT-52, DAT-74, ARC-07, TX-28, BET-11, DAT-16): SQLite transactions are the
ones the code assumes.
Everything here runs on a real file database whose engine went through
`_register_sqlite_pragmas` — an in-memory engine without it would not exercise
`BEGIN`/`BEGIN IMMEDIATE`, `synchronous` or the lock at all — with the lock wait
shortened to 100 ms so a busy database is provoked by simply holding the write
lock from a second session.
"""
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from fastapi import Response
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from sqlalchemy import func, select, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
from app.config import settings
from tests.unit.fake_network import FakeNetwork, rpc_error
from app.db import base as db_base
from app.db.base import Base, begin_immediate
from app.db.models import (
AuditLog,
PendingTransaction,
RoundConfig,
RoundParticipant,
User,
UtxoEvent,
Withdrawal,
)
from app.deposits.service import (
credit_confirmed_utxos,
mark_utxos_spent_externally,
reinstate_reappeared_utxos,
)
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
_EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
@pytest.fixture
async def engine(tmp_path, monkeypatch):
monkeypatch.setattr(db_base, "_SQLITE_BUSY_TIMEOUT_MS", 100)
eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/b88.db")
db_base._register_sqlite_pragmas(eng)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest.fixture
def session_factory(engine):
return async_sessionmaker(engine, expire_on_commit=False)
@pytest.fixture
def keyed(tmp_path, monkeypatch):
"""A real master key, so the HD wallet can derive and sign."""
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
yield
hd._account_key = None
@pytest.fixture
async def funded(session_factory, keyed):
"""Fee address + one user holding 15 PLM in a single UTXO."""
async with session_factory() as session:
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
user = User(username="u0", password_hash="x", derivation_index=0, address=derive_user_address(0))
session.add(user)
await session.commit()
session.add(UtxoEvent(user_id=user.id, txid="00" * 32, vout=0, amount_sats=1_500_000_000, confirmed_height=100))
await session.commit()
user_id = user.id
yield user_id
@asynccontextmanager
async def _holding_write_lock(session_factory):
"""Another writer holding SQLite's write lock: every begin_immediate elsewhere
times out after the (shortened) busy_timeout."""
async with session_factory() as holder:
await begin_immediate(holder)
try:
yield
finally:
await holder.rollback()
class _Client:
"""Fake Electrum client. `on_broadcast` runs first (to grab the lock *after* the
coins have gone out); `fail` makes the broadcast raise."""
def __init__(self, on_broadcast=None, fail=False):
self.broadcasted: list[str] = []
self._on_broadcast = on_broadcast
self._fail = fail
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
if self._on_broadcast is not None:
await self._on_broadcast()
if self._fail:
raise rpc_error("mempool conflict")
return "txid"
# --- the engine setup: ARC-07 / DAT-52 ---------------------------------------------
async def test_every_connection_is_full_sync_wal_with_no_driver_transactions(engine):
"""DAT-52: synchronous=FULL, WAL, busy_timeout — and the driver's implicit BEGIN
switched off (isolation_level None), so the transaction is the one we emit."""
async with engine.connect() as conn:
assert (await conn.exec_driver_sql("PRAGMA synchronous")).scalar() == 2 # FULL
assert (await conn.exec_driver_sql("PRAGMA journal_mode")).scalar().lower() == "wal"
assert (await conn.exec_driver_sql("PRAGMA busy_timeout")).scalar() == 100
raw = await conn.get_raw_connection()
assert raw.driver_connection.isolation_level is None
async def test_a_read_starts_a_real_transaction_with_a_stable_snapshot(session_factory):
"""DAT-74: with the legacy driver mode a SELECT ran outside any transaction and
saw whatever had been committed since the previous statement; now the first
statement opens BEGIN and the reads that follow share one snapshot."""
async with session_factory() as setup:
setup.add(User(username="a", password_hash="x", derivation_index=0, address="a"))
await setup.commit()
async with session_factory() as reader, session_factory() as writer:
assert await reader.scalar(select(func.count()).select_from(User)) == 1
raw = await (await reader.connection()).get_raw_connection()
assert raw.driver_connection.in_transaction
writer.add(User(username="b", password_hash="x", derivation_index=1, address="b"))
await writer.commit()
assert await reader.scalar(select(func.count()).select_from(User)) == 1 # snapshot held
await reader.commit()
assert await reader.scalar(select(func.count()).select_from(User)) == 2
async def test_commit_and_rollback_still_work_without_the_drivers_transactions(session_factory):
async with session_factory() as session:
session.add(User(username="kept", password_hash="x", derivation_index=0, address="k"))
await session.commit()
session.add(User(username="gone", password_hash="x", derivation_index=1, address="g"))
await session.flush()
await session.rollback()
async with session_factory() as session:
names = (await session.scalars(select(User.username))).all()
assert names == ["kept"]
# --- begin_immediate: DAT-74 ------------------------------------------------------
async def test_begin_immediate_takes_the_write_lock_before_any_read(session_factory):
async with session_factory() as first, session_factory() as second:
await begin_immediate(first)
with pytest.raises(OperationalError, match="locked"):
await begin_immediate(second)
await first.rollback()
await begin_immediate(second) # free again once the holder is done
async def test_a_plain_reader_is_not_blocked_by_the_lock_holder(session_factory):
"""WAL: only writers queue; BEGIN IMMEDIATE must not turn every GET into one."""
async with session_factory() as holder, session_factory() as reader:
await begin_immediate(holder)
assert await reader.scalar(select(func.count()).select_from(User)) == 0
async def test_begin_immediate_closes_the_readonly_transaction_that_preceded_it(session_factory):
"""The callers arrive with the authentication lookup already in a transaction."""
async with session_factory() as session:
await session.scalar(select(func.count()).select_from(User))
assert session.in_transaction()
await begin_immediate(session)
assert session.in_transaction()
raw = await (await session.connection()).get_raw_connection()
assert raw.driver_connection.in_transaction
# and it really holds the write lock
async with session_factory() as other:
with pytest.raises(OperationalError):
await begin_immediate(other)
async def test_begin_immediate_keeps_loaded_objects_usable(session_factory, funded):
async with session_factory() as session:
user = await session.get(User, funded)
await begin_immediate(session)
assert user.username == "u0" # no lazy load needed after the commit it did
async def test_begin_immediate_refuses_a_session_with_unflushed_changes(session_factory):
async with session_factory() as session:
session.add(User(username="x", password_hash="x", derivation_index=0, address="x"))
with pytest.raises(RuntimeError, match="unflushed"):
await begin_immediate(session)
session.expunge_all()
async with session_factory() as session:
user = User(username="y", password_hash="x", derivation_index=1, address="y")
session.add(user)
await session.commit()
user.username = "z" # dirty
with pytest.raises(RuntimeError, match="unflushed"):
await begin_immediate(session)
await session.rollback()
async def test_begin_immediate_is_harmless_on_an_engine_without_the_sqlite_setup():
"""The service tests run on plain in-memory engines: the option is never read."""
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_sessionmaker(eng, expire_on_commit=False)() as session:
await begin_immediate(session)
session.add(User(username="a", password_hash="x", derivation_index=0, address="a"))
await session.commit()
await eng.dispose()
# --- place_bet: BET-11, DAT-16, TX-28 ---------------------------------------------
async def _bet_state(session_factory, user_id):
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
pendings = (await session.scalars(select(PendingTransaction))).all()
utxo = await session.scalar(select(UtxoEvent).where(UtxoEvent.user_id == user_id))
return participants, pendings, utxo
async def test_place_bet_on_a_real_database_commits_and_promotes(session_factory, funded):
client = _Client()
async with session_factory() as session:
user = await session.get(User, funded)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert [p.status for p in participants] == ["broadcast"]
assert [p.status for p in pendings] == ["pending"]
assert utxo.spent_txid == participants[0].bet_txid
assert len(client.broadcasted) == 1
async def test_place_bet_answers_service_busy_when_the_lock_is_not_granted(session_factory, funded):
"""BET-11 / api.md `service_busy`: refused before anything is built or sent."""
client = _Client()
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "service_busy"
assert client.broadcasted == []
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert participants == [] and pendings == []
assert utxo.spent_txid is None
async def test_place_bet_can_be_retried_after_a_busy_refusal(session_factory, funded):
client = _Client()
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError):
await place_bet(session, client, user)
async with session_factory() as session:
user = await session.get(User, funded)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
async def test_place_bet_maps_a_lock_timeout_at_the_phase_one_commit_to_service_busy_not_round_closing(
session_factory, funded
):
"""DAT-16: an OperationalError at the compare-and-set / commit is the lock wait
expiring, never a snapshot conflict (SQLite has none) — so not `round_closing`.
Rolled back, nothing sent."""
client = _Client()
async with session_factory() as session:
user = await session.get(User, funded)
real_commit = session.commit
calls = {"n": 0}
async def flaky_commit():
calls["n"] += 1
if calls["n"] == 1: # the phase-1 commit (begin_immediate's own commit is a no-op here)
raise OperationalError("COMMIT", {}, Exception("database is locked"))
await real_commit()
session.commit = flaky_commit
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "service_busy"
assert client.broadcasted == []
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert participants == [] and pendings == [] and utxo.spent_txid is None
async def test_place_bet_after_a_broadcast_never_undoes_it_when_the_lock_is_lost(session_factory, funded):
"""TX-28 / DAT-74: the tx is on the network, so a busy database afterwards leaves
the rows `building` for the reconciler instead of failing or releasing anything;
the caller still gets the bet, with the status the database holds."""
holder_cm = _holding_write_lock(session_factory)
async def grab_lock():
await holder_cm.__aenter__()
client = _Client(on_broadcast=grab_lock)
try:
async with session_factory() as session:
user = await session.get(User, funded)
participant = await place_bet(session, client, user)
finally:
await holder_cm.__aexit__(None, None, None)
assert participant.status == "building"
assert len(client.broadcasted) == 1
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert [p.status for p in participants] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == participants[0].bet_txid # still reserved
async with session_factory() as session:
events = (await session.scalars(select(AuditLog.event_type))).all()
assert "bet_placed" not in events and "bet_broadcast_failed" not in events
async def test_place_bet_refused_broadcast_with_a_busy_database_leaves_the_rows_building(session_factory, funded):
"""A refused broadcast whose release cannot be written stays `building` (the
reconciler decides), and the caller still gets `broadcast_failed`."""
holder_cm = _holding_write_lock(session_factory)
async def grab_lock():
await holder_cm.__aenter__()
client = _Client(on_broadcast=grab_lock, fail=True)
try:
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user, network=FakeNetwork(known=False))
finally:
await holder_cm.__aexit__(None, None, None)
assert excinfo.value.code == "broadcast_failed"
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert [p.status for p in participants] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == participants[0].bet_txid
async def test_place_bet_refused_broadcast_still_releases_when_the_database_is_free(session_factory, funded):
client = _Client(fail=True)
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user, network=FakeNetwork(known=False))
assert excinfo.value.code == "broadcast_failed"
participants, pendings, utxo = await _bet_state(session_factory, funded)
assert participants == [] and pendings == [] and utxo.spent_txid is None
async def test_concurrent_bets_of_one_user_cannot_both_select_the_same_utxo(session_factory, funded):
"""DAT-74: with the lock taken before the reads, two place_bet calls for the same
user (which the per-user lock normally forbids) cannot both read the UTXO as free."""
import asyncio
client = _Client()
async def attempt():
async with session_factory() as session:
user = await session.get(User, funded)
return await place_bet(session, client, user)
results = await asyncio.gather(attempt(), attempt(), return_exceptions=True)
ok = [r for r in results if not isinstance(r, Exception)]
assert len(ok) == 1
failures = [r for r in results if isinstance(r, Exception)]
assert len(failures) == 1 and isinstance(failures[0], BetError)
assert len(client.broadcasted) == 1
# --- request_withdrawal ----------------------------------------------------------
async def _withdrawal_state(session_factory, user_id):
async with session_factory() as session:
return (
(await session.scalars(select(Withdrawal))).all(),
(await session.scalars(select(PendingTransaction))).all(),
await session.scalar(select(UtxoEvent).where(UtxoEvent.user_id == user_id)),
)
async def test_withdrawal_on_a_real_database_commits_and_promotes(session_factory, funded):
client = _Client()
async with session_factory() as session:
user = await session.get(User, funded)
withdrawal = await request_withdrawal(session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000)
assert withdrawal.status == "broadcast"
withdrawals, pendings, utxo = await _withdrawal_state(session_factory, funded)
assert [w.status for w in withdrawals] == ["broadcast"]
assert [p.status for p in pendings] == ["pending"]
assert utxo.spent_txid == withdrawals[0].txid
async def test_withdrawal_answers_service_busy_when_the_lock_is_not_granted(session_factory, funded):
client = _Client()
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000)
assert excinfo.value.code == "service_busy"
assert client.broadcasted == []
withdrawals, pendings, utxo = await _withdrawal_state(session_factory, funded)
assert withdrawals == [] and pendings == [] and utxo.spent_txid is None
async def test_withdrawal_validation_errors_do_not_wait_for_the_lock(session_factory, funded):
"""The checks that need no database answer immediately, lock or not."""
client = _Client()
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(session, client, user, "not-an-address", 1_000_000_000)
assert excinfo.value.code == "invalid_address"
async def test_withdrawal_after_a_broadcast_never_undoes_it_when_the_lock_is_lost(session_factory, funded):
holder_cm = _holding_write_lock(session_factory)
async def grab_lock():
await holder_cm.__aenter__()
client = _Client(on_broadcast=grab_lock)
try:
async with session_factory() as session:
user = await session.get(User, funded)
withdrawal = await request_withdrawal(session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000)
finally:
await holder_cm.__aexit__(None, None, None)
assert withdrawal.status == "building"
withdrawals, pendings, utxo = await _withdrawal_state(session_factory, funded)
assert [w.status for w in withdrawals] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == withdrawals[0].txid
async def test_withdrawal_refused_broadcast_with_a_busy_database_leaves_the_rows_building(session_factory, funded):
holder_cm = _holding_write_lock(session_factory)
async def grab_lock():
await holder_cm.__aenter__()
client = _Client(on_broadcast=grab_lock, fail=True)
try:
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(
session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000, network=FakeNetwork(known=False)
)
finally:
await holder_cm.__aexit__(None, None, None)
assert excinfo.value.code == "broadcast_failed"
withdrawals, pendings, utxo = await _withdrawal_state(session_factory, funded)
assert [w.status for w in withdrawals] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == withdrawals[0].txid
async def test_withdrawal_refused_broadcast_still_releases_when_the_database_is_free(session_factory, funded):
client = _Client(fail=True)
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(WithdrawalError):
await request_withdrawal(
session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000, network=FakeNetwork(known=False)
)
withdrawals, pendings, utxo = await _withdrawal_state(session_factory, funded)
assert [w.status for w in withdrawals] == ["failed"]
assert [p.status for p in pendings] == ["failed"]
assert utxo.spent_txid is None
# --- the HTTP mapping: api.md `service_busy` -> 503 ---------------------------------
def _fake_request(client):
listener = SimpleNamespace(client=client)
class _Locks:
@asynccontextmanager
async def acquire(self, user_id):
yield
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=_Locks())))
async def test_bet_route_answers_503_for_service_busy(session_factory, funded):
from fastapi import HTTPException
from app.api.routes.bets import create_bet
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(HTTPException) as excinfo:
await create_bet(_fake_request(_Client()), Response(), user=user, session=session)
assert excinfo.value.status_code == 503
assert excinfo.value.detail["code"] == "service_busy"
async def test_withdrawal_route_answers_503_for_service_busy(session_factory, funded):
from fastapi import HTTPException
from app.api.routes.withdrawals import WithdrawalRequest, create_withdrawal
body = WithdrawalRequest(external_address=_EXTERNAL_ADDRESS, amount_sats=1_000_000_000)
async with _holding_write_lock(session_factory):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(HTTPException) as excinfo:
await create_withdrawal(body, _fake_request(_Client()), Response(), user=user, session=session)
assert excinfo.value.status_code == 503
assert excinfo.value.detail["code"] == "service_busy"
# --- registration ----------------------------------------------------------------
@pytest.fixture
async def auth_client(session_factory, funded, monkeypatch):
from fastapi import FastAPI
from app.auth.routes import router
from app.db.session import get_session
monkeypatch.setattr(settings, "jwt_secret", "x" * 32)
app = FastAPI()
app.include_router(router)
async def _session():
async with session_factory() as session:
yield session
app.dependency_overrides[get_session] = _session
app.state.electrum_listener = SimpleNamespace(address_for_new_user=lambda *a, **k: None)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac:
yield ac
async def test_concurrent_registrations_get_distinct_derivation_indexes(auth_client, session_factory):
"""DAT-74: the max(derivation_index) read and the insert are one transaction under
the write lock, so two simultaneous signups cannot pick the same index."""
import asyncio
responses = await asyncio.gather(
*(auth_client.post("/auth/register", json={"username": f"racer{i}", "password": "long-enough-1"}) for i in range(4))
)
assert [r.status_code for r in responses] == [201] * 4
async with session_factory() as session:
indexes = (await session.scalars(select(User.derivation_index))).all()
assert len(indexes) == len(set(indexes)) == 5 # the funded fixture user plus four
async def test_registration_answers_503_service_busy_when_the_lock_is_not_granted(auth_client, session_factory):
async with _holding_write_lock(session_factory):
resp = await auth_client.post("/auth/register", json={"username": "late", "password": "long-enough-1"})
assert resp.status_code == 503
assert resp.json()["detail"]["code"] == "service_busy"
async with session_factory() as session:
assert await session.scalar(select(User).where(User.username == "late")) is None
# --- deposits --------------------------------------------------------------------
async def test_credit_confirmed_utxos_needs_the_write_lock(session_factory, funded):
entries = [{"tx_hash": "ab" * 32, "tx_pos": 0, "height": 100, "value": 5_000_000}]
async with _holding_write_lock(session_factory):
async with session_factory() as session:
with pytest.raises(OperationalError):
await credit_confirmed_utxos(session, funded, entries)
async with session_factory() as session:
assert await credit_confirmed_utxos(session, funded, entries) == 1
async def test_mark_utxos_spent_externally_needs_the_write_lock(session_factory, funded):
async with session_factory() as session:
utxo_id = (await session.scalar(select(UtxoEvent.id).where(UtxoEvent.user_id == funded)))
async with _holding_write_lock(session_factory):
async with session_factory() as session:
with pytest.raises(OperationalError):
await mark_utxos_spent_externally(session, funded, [utxo_id])
async with session_factory() as session:
assert await mark_utxos_spent_externally(session, funded, [utxo_id]) == 1
async def test_reinstate_takes_the_lock_only_when_there_is_something_to_reinstate(session_factory, funded):
"""It runs on every refresh: with nothing flagged it must not queue for the lock."""
async with session_factory() as session:
utxo = await session.scalar(select(UtxoEvent).where(UtxoEvent.user_id == funded))
utxo.spent_txid = "external-spend"
await session.commit()
entry = {"tx_hash": utxo.txid, "tx_pos": utxo.vout, "height": 100, "value": utxo.amount_sats}
async with _holding_write_lock(session_factory):
async with session_factory() as session:
# nothing flagged reappears -> no lock needed, no error
assert await reinstate_reappeared_utxos(session, funded, []) == 0
async with session_factory() as session:
with pytest.raises(OperationalError):
await reinstate_reappeared_utxos(session, funded, [entry])
async with session_factory() as session:
assert await reinstate_reappeared_utxos(session, funded, [entry]) == 1
utxo = await session.scalar(select(UtxoEvent).where(UtxoEvent.user_id == funded))
assert utxo.spent_txid is None
# --- background writers: scheduler, poller, reconciler, RBF -------------------------
async def test_scheduler_tick_state_transition_needs_the_write_lock_and_is_retried(session_factory, funded):
"""DAT-74: a state transition under a busy database raises out of the tick (run()
logs it and tries again five seconds later) instead of half-writing."""
from datetime import datetime, timedelta, timezone
from app.db.models import Round
from app.rounds.scheduler import RoundScheduler
async with session_factory() as session:
session.add(Round(status="open", opened_at=datetime.now(timezone.utc) - timedelta(seconds=99999), duration_seconds=1))
await session.commit()
scheduler = RoundScheduler(session_factory, SimpleNamespace(client=object(), tip_height=1, tip_header_hex="00"))
async with _holding_write_lock(session_factory):
with pytest.raises(OperationalError):
await scheduler._tick()
async with session_factory() as session:
assert (await session.scalar(select(Round.status))) == "open" # untouched
await scheduler._tick() # lock free: the same tick now goes through
async with session_factory() as session:
assert (await session.scalar(select(Round.status))) == "closed" # no participants
def _payout_scheduler(session_factory, client):
from app.rounds.scheduler import RoundScheduler
from tests.unit.test_scheduler import FakePayoutListener
return RoundScheduler(session_factory, FakePayoutListener(client))
class _PayoutClient:
def __init__(self, entries, hook_listunspent=None, hook_broadcast=None):
self._entries = entries
self._hook_listunspent = hook_listunspent
self._hook_broadcast = hook_broadcast
self.broadcasted: list[str] = []
async def listunspent(self, scripthash):
if self._hook_listunspent:
await self._hook_listunspent()
return self._entries
async def broadcast(self, raw_tx_hex):
self.broadcasted.append(raw_tx_hex)
if self._hook_broadcast:
await self._hook_broadcast()
return "txid"
async def _seed_payout(session_factory):
from tests.unit.test_scheduler import _POOL_AMOUNT_SATS, _seed_paying_out_round
await _seed_paying_out_round(session_factory)
return [{"tx_hash": "33" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
async def test_trigger_payout_defers_when_the_database_is_busy_before_reading(session_factory, keyed):
from app.db.models import Round
entries = await _seed_payout(session_factory)
client = _PayoutClient(entries)
async with _holding_write_lock(session_factory):
await _payout_scheduler(session_factory, client)._trigger_payout(1)
assert client.broadcasted == []
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).all() == []
assert (await session.get(Round, 1)).payout_txid is None
async def test_trigger_payout_does_not_broadcast_when_it_cannot_persist_the_intent(session_factory, keyed):
"""The lock is lost between building and persisting: nothing may be sent."""
entries = await _seed_payout(session_factory)
holder_cm = _holding_write_lock(session_factory)
async def grab():
await holder_cm.__aenter__()
client = _PayoutClient(entries, hook_listunspent=grab)
try:
await _payout_scheduler(session_factory, client)._trigger_payout(1)
finally:
await holder_cm.__aexit__(None, None, None)
assert client.broadcasted == []
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).all() == []
async def test_trigger_payout_leaves_the_row_building_when_the_lock_is_lost_after_the_broadcast(
session_factory, keyed
):
entries = await _seed_payout(session_factory)
holder_cm = _holding_write_lock(session_factory)
async def grab():
await holder_cm.__aenter__()
client = _PayoutClient(entries, hook_broadcast=grab)
try:
await _payout_scheduler(session_factory, client)._trigger_payout(1)
finally:
await holder_cm.__aexit__(None, None, None)
assert len(client.broadcasted) == 1
async with session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "building" # the reconciler promotes it (TX-28)
events = (await session.scalars(select(AuditLog.event_type))).all()
assert "payout_sent" not in events
async def _seed_pending_withdrawal(session_factory, funded, status="pending", **kw):
from datetime import datetime, timedelta, timezone
async with session_factory() as session:
wd = Withdrawal(user_id=funded, external_address=_EXTERNAL_ADDRESS, amount_requested_sats=1, amount_sent_sats=1, txid="abcd", status="broadcast")
session.add(wd)
await session.flush()
row = PendingTransaction(
kind="withdrawal",
withdrawal_id=wd.id,
user_id=funded,
current_txid="abcd",
fee_rate_sat_vb=1,
raw_tx_hex="00",
status=status,
broadcast_at=datetime.now(timezone.utc) - timedelta(days=1),
**kw,
)
session.add(row)
await session.commit()
return row.id
async def test_confirmation_poller_writes_under_the_lock(session_factory, funded):
from app.tx.confirmation import poll_once
import app.withdrawals.confirmation # noqa: F401 (registers the handler)
row_id = await _seed_pending_withdrawal(session_factory, funded)
class Client:
async def get_history(self, scripthash):
return [{"tx_hash": "abcd", "height": 7}]
async with _holding_write_lock(session_factory):
with pytest.raises(OperationalError):
await poll_once(session_factory, Client(), FakeNetwork())
async with session_factory() as session:
assert (await session.get(PendingTransaction, row_id)).status == "pending"
assert await poll_once(session_factory, Client(), FakeNetwork()) == 1
async with session_factory() as session:
assert (await session.get(PendingTransaction, row_id)).status == "confirmed"
assert (await session.scalar(select(Withdrawal.status))) == "confirmed"
async def test_reconciler_resolves_under_the_lock(session_factory, funded):
from app.tx.reconcile import reconcile_once
row_id = await _seed_pending_withdrawal(session_factory, funded, status="building")
class Client:
async def get_history(self, scripthash):
return [{"tx_hash": "abcd", "height": 0}]
async with _holding_write_lock(session_factory):
with pytest.raises(OperationalError):
await reconcile_once(session_factory, Client())
async with session_factory() as session:
assert (await session.get(PendingTransaction, row_id)).status == "building"
assert await reconcile_once(session_factory, Client()) == 1
async with session_factory() as session:
assert (await session.get(PendingTransaction, row_id)).status == "pending"
async def test_bump_fee_persists_under_the_lock(session_factory, funded):
"""The persist phase of an RBF bump is a money-moving transaction like any other
(DAT-74): under a busy database it raises out of bump_fee rather than committing
a half-applied retarget."""
from datetime import datetime, timedelta, timezone
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from app.tx.broadcast import bump_fee
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import Utxo, build_signed_transaction
signer = derive_user_key(0)
built = build_signed_transaction(
signing_key=signer,
from_script=script.p2wpkh(signer.to_public()),
utxos=[Utxo("11" * 32, 0, 150_000_000)],
to_address=_EXTERNAL_ADDRESS,
amount_sats=10_000_000,
change_address=derive_user_address(0),
fee_rate_sat_vb=1,
)
async with session_factory() as session:
session.add(
PendingTransaction(
kind="bet",
user_id=funded,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
)
await session.commit()
class Client:
broadcasted: list = []
async def get_transaction(self, txid, verbose=False):
tx = Transaction(
vin=[TransactionInput(b"\x00" * 32, 0)],
vout=[TransactionOutput(150_000_000, script.Script(b"\x00\x14" + b"\x00" * 20))],
)
return tx.serialize().hex()
async def broadcast(self, raw_hex):
self.broadcasted.append(raw_hex)
return "x"
holder_cm = _holding_write_lock(session_factory)
class HookedClient(Client):
async def broadcast(self, raw_hex):
await super().broadcast(raw_hex)
await holder_cm.__aenter__() # the lock is taken right after the send
client = HookedClient()
try:
with pytest.raises(OperationalError):
await bump_fee(session_factory, client, 1)
finally:
await holder_cm.__aexit__(None, None, None)
async with session_factory() as session:
row = await session.get(PendingTransaction, 1)
assert row.current_txid == built.txid # nothing half-applied
assert len(client.broadcasted) == 1