2026-07-21 10:25:57 +02:00
|
|
|
import pytest
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
|
|
|
|
|
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
|
|
|
|
|
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
|
|
|
|
|
from app.db.base import Base
|
|
|
|
|
from app.db.models import PendingTransaction, Round, RoundParticipant
|
|
|
|
|
from app.tx.confirmation import poll_once
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeClient:
|
|
|
|
|
def __init__(self, confirmations_by_txid: dict[str, int]):
|
|
|
|
|
self._confirmations = confirmations_by_txid
|
|
|
|
|
|
|
|
|
|
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
|
|
|
|
|
return {"confirmations": self._confirmations.get(txid, 0)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_bet_confirmation_marks_participant_confirmed(session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=1, status="open"))
|
|
|
|
|
session.add(
|
|
|
|
|
RoundParticipant(
|
|
|
|
|
round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
session.add(
|
|
|
|
|
PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
client = FakeClient({"tx1": 1})
|
|
|
|
|
confirmed = await poll_once(session_factory, client)
|
|
|
|
|
assert confirmed == 1
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
participant = (await session.scalars(select(RoundParticipant))).one()
|
|
|
|
|
assert participant.status == "confirmed"
|
|
|
|
|
assert participant.confirmed_at is not None
|
|
|
|
|
pending = (await session.scalars(select(PendingTransaction))).one()
|
|
|
|
|
assert pending.status == "confirmed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_unconfirmed_tx_is_left_pending(session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=2, status="open"))
|
|
|
|
|
session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast"))
|
|
|
|
|
session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
client = FakeClient({"tx2": 0})
|
|
|
|
|
confirmed = await poll_once(session_factory, client)
|
|
|
|
|
assert confirmed == 0
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
participant = (await session.scalars(select(RoundParticipant))).one()
|
|
|
|
|
assert participant.status == "broadcast"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_payout_confirmation_closes_round(session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=3, status="paying_out", payout_txid="tx3"))
|
|
|
|
|
session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
client = FakeClient({"tx3": 2})
|
|
|
|
|
confirmed = await poll_once(session_factory, client)
|
|
|
|
|
assert confirmed == 1
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
round_ = await session.get(Round, 3)
|
|
|
|
|
assert round_.status == "closed"
|
2026-07-27 00:31:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExplodingClient:
|
|
|
|
|
"""Answers for one txid and raises for the other — a tx the server no longer
|
|
|
|
|
knows (dropped from the mempool, replaced by a bump)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, known: dict[str, int], exploding_txid: str):
|
|
|
|
|
self._known = known
|
|
|
|
|
self._exploding = exploding_txid
|
|
|
|
|
|
|
|
|
|
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
|
|
|
|
|
if txid == self._exploding:
|
|
|
|
|
raise RuntimeError("missing transaction")
|
|
|
|
|
return {"confirmations": self._known.get(txid, 0)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_one_unresolvable_txid_does_not_block_the_others(session_factory):
|
|
|
|
|
"""B-03: the lookup used to be unguarded, so a single unknown txid aborted the
|
|
|
|
|
whole pass — nothing confirmed again until an operator intervened, which in turn
|
|
|
|
|
meant no round could ever close."""
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=10, status="open"))
|
|
|
|
|
session.add(
|
|
|
|
|
RoundParticipant(round_id=10, user_id=1, bet_amount_sats=1_000, bet_txid="good", status="broadcast")
|
|
|
|
|
)
|
|
|
|
|
session.add(
|
|
|
|
|
PendingTransaction(
|
|
|
|
|
kind="bet", round_id=10, user_id=2, current_txid="gone", fee_rate_sat_vb=1, raw_tx_hex="00",
|
|
|
|
|
status="pending",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
session.add(
|
|
|
|
|
PendingTransaction(
|
|
|
|
|
kind="bet", round_id=10, user_id=1, current_txid="good", fee_rate_sat_vb=1, raw_tx_hex="00",
|
|
|
|
|
status="pending",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
confirmed = await poll_once(session_factory, ExplodingClient({"good": 1}, exploding_txid="gone"))
|
|
|
|
|
assert confirmed == 1 # the healthy one still got processed
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 10))).one()
|
|
|
|
|
assert participant.status == "confirmed"
|
|
|
|
|
rows = {p.current_txid: p.status for p in (await session.scalars(select(PendingTransaction))).all()}
|
|
|
|
|
assert rows["good"] == "confirmed"
|
|
|
|
|
assert rows["gone"] == "pending" # left for the reconciler to judge, not abandoned here
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
|
|
|
|
|
"""B-02: the handler used to match on bet_txid, so a bumped bet confirmed under
|
|
|
|
|
a txid no participant carried — the participant stayed "broadcast" forever and
|
|
|
|
|
the round could never close. It now resolves by (round_id, user_id)."""
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=11, status="open"))
|
|
|
|
|
session.add(
|
|
|
|
|
RoundParticipant(
|
|
|
|
|
round_id=11, user_id=7, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
session.add(
|
|
|
|
|
PendingTransaction(
|
|
|
|
|
kind="bet", round_id=11, user_id=7, current_txid="bumped-txid", fee_rate_sat_vb=2,
|
|
|
|
|
raw_tx_hex="00", status="pending", replaced_by_txid="old-txid",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
assert await poll_once(session_factory, FakeClient({"bumped-txid": 1})) == 1
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 11))).one()
|
|
|
|
|
assert participant.status == "confirmed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
session.add(Round(id=12, status="paying_out", payout_txid="old-payout"))
|
|
|
|
|
session.add(
|
|
|
|
|
PendingTransaction(
|
|
|
|
|
kind="payout", round_id=12, current_txid="bumped-payout", fee_rate_sat_vb=2,
|
|
|
|
|
raw_tx_hex="00", status="pending",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
assert await poll_once(session_factory, FakeClient({"bumped-payout": 1})) == 1
|
|
|
|
|
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
assert (await session.get(Round, 12)).status == "closed"
|