Files
plm-lottery/tests/unit/test_broadcast_outcome.py
T

543 lines
24 KiB
Python
Raw Normal View History

"""B-76 (TX-04, TX-20, TX-21, TX-05, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-09):
an ambiguous broadcast is never rolled back as if it had been refused.
Only an explicit JSON-RPC refusal that the whole network then confirms it does not know
may release coins. A timeout, a dropped connection, or a refusal nobody could verify
leaves the rows `building` for the reconciler. Each of these tests fails against the
old code, where *any* exception from the broadcast undid everything.
"""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import Response
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumEndpoint, ElectrumError
from app.electrum.listener import ElectrumListener
from app.rounds.events import broadcaster
from app.rounds.scheduler import RoundScheduler
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.withdrawals.service import WithdrawalError, request_withdrawal
from tests.unit.fake_network import FakeNetwork, rpc_error
from tests.unit.test_bets import _make_funded_user, session_factory # noqa: F401 (fixture)
from tests.unit.test_scheduler import ( # noqa: F401 (fixtures and helpers shared with the payout tests)
FakePayoutClient,
FakePayoutListener,
_POOL_AMOUNT_SATS,
_seed_paying_out_round,
payout_session_factory,
)
_EXTERNAL = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
_FUNDS = 3_000_000_000
class _FailingClient:
"""A client whose broadcast fails the way `exc` says."""
def __init__(self, exc: BaseException):
self._exc = exc
self.broadcasted: list[str] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
raise self._exc
_UNCERTAIN_FAILURES = [
pytest.param(ElectrumError("blockchain.transaction.broadcast timed out after 15s"), id="timeout-after-write"),
pytest.param(asyncio.TimeoutError(), id="bare-timeout"),
pytest.param(ElectrumError("connection closed"), id="disconnect"),
pytest.param(ElectrumError("write failed: broken pipe"), id="write-error"),
pytest.param(ConnectionResetError("reset by peer"), id="connection-reset"),
]
async def _bet_rows(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.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
return participants, pendings, utxo, events
# --- bets ----------------------------------------------------------------------
@pytest.mark.parametrize("failure", _UNCERTAIN_FAILURES)
async def test_a_bet_whose_broadcast_ends_without_an_answer_is_left_building(session_factory, failure):
"""TX-20 / BET-23: nothing is released, the player gets the `building` participant,
and the network is never asked to verify (there was no refusal to verify)."""
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
network = FakeNetwork(known=False) # would say "release" if asked — it must not be
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, _FailingClient(failure), user, network=network)
assert participant.status == "building"
assert not queue.empty() # SSE
finally:
broadcaster.unsubscribe(queue)
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
assert [p.status for p in participants] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == participants[0].bet_txid # the coins stay committed to the tx
assert "bet_broadcast_failed" not in events and "bet_placed" not in events
assert network.asked == [] and network.fanned_out == []
async with session_factory() as session:
assert (await session.get(User, user_id)).cached_balance_sats == 0 # not given back
async def test_a_refused_bet_that_another_server_knows_counts_as_accepted(session_factory):
"""TX-04: the active server refused ("txn-mempool-conflict", whatever it said) but a
server of the network returns the transaction — it is on the network, so the bet
stands and is promoted. Releasing here sent the sats to the pool without a participant."""
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
network = FakeNetwork(known=True)
client = _FailingClient(rpc_error("some wording we have never seen"))
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user, network=network)
assert participant.status == "broadcast"
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
assert [p.status for p in pendings] == ["pending"]
assert "bet_placed" in events and "bet_broadcast_failed" not in events
assert network.asked == [participants[0].bet_txid]
assert len(network.fanned_out) == 1 # TX-21: sent to the other servers too
async def test_a_refused_bet_nobody_knows_is_released(session_factory):
"""TX-04 / BET-16: a verified refusal still undoes everything and answers 502."""
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
network = FakeNetwork(known=False)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, _FailingClient(rpc_error("min relay fee not met")), user, network=network)
assert excinfo.value.code == "broadcast_failed"
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
assert participants == [] and pendings == [] and utxo.spent_txid is None
assert "bet_broadcast_failed" in events
assert network.fanned_out == []
async def test_a_refusal_the_network_cannot_verify_releases_nothing(session_factory):
"""TX-04: too few servers answered the verification — the outcome is uncertain, so
the bet stays `building` instead of being rolled back on the refusal's word alone."""
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(
session, _FailingClient(rpc_error("min relay fee not met")), user, network=FakeNetwork(known=None)
)
assert participant.status == "building"
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
assert [p.status for p in pendings] == ["building"] and utxo.spent_txid == participants[0].bet_txid
assert "bet_broadcast_failed" not in events
async def test_a_refusal_with_no_network_to_verify_it_releases_nothing(session_factory):
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, _FailingClient(rpc_error("min relay fee not met")), user)
assert participant.status == "building"
@pytest.mark.parametrize(
"message",
[
"txn-already-known",
"txn-already-in-mempool",
"Transaction already in block chain",
"the transaction was rejected by network rules.\n\ntxn-already-in-mempool",
],
)
async def test_already_known_is_a_shortcut_that_skips_the_verification(session_factory, message):
"""TX-04: a recognised "already known" message counts as accepted without asking
anyone; it is a shortcut, never a condition of safety."""
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
network = FakeNetwork(known=False)
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, _FailingClient(rpc_error(message)), user, network=network)
assert participant.status == "broadcast"
assert network.asked == []
assert len(network.fanned_out) == 1
async def test_the_bet_route_answers_202_for_an_uncertain_outcome_and_201_for_success(session_factory):
"""API-09 / BET-23: `building` is HTTP 202; a promoted bet keeps 201."""
from contextlib import asynccontextmanager
from app.api.routes.bets import create_bet
class _Locks:
@asynccontextmanager
async def acquire(self, user_id):
yield
def _request(client, network):
listener = SimpleNamespace(client=client, **{"transaction_known": network.transaction_known,
"fan_out_transaction": network.fan_out_transaction})
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=_Locks())))
first = await _make_funded_user(session_factory, 4, _FUNDS)
second = await _make_funded_user(session_factory, 5, _FUNDS)
response = Response()
async with session_factory() as session:
user = await session.get(User, first)
body = await create_bet(
_request(_FailingClient(ElectrumError("connection closed")), FakeNetwork()), response, user=user, session=session
)
assert response.status_code == 202 and body.status == "building"
class _OkClient:
async def broadcast(self, raw_tx_hex):
return "txid"
response = Response()
async with session_factory() as session:
user = await session.get(User, second)
body = await create_bet(_request(_OkClient(), FakeNetwork()), response, user=user, session=session)
assert response.status_code != 202 and body.status == "broadcast" # the route's declared 201 stands
# --- withdrawals -----------------------------------------------------------------
async def _withdrawal_rows(session_factory, user_id):
async with session_factory() as session:
withdrawals = (await session.scalars(select(Withdrawal))).all()
pendings = (await session.scalars(select(PendingTransaction))).all()
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
return withdrawals, pendings, utxo, events
@pytest.mark.parametrize("failure", _UNCERTAIN_FAILURES)
async def test_a_withdrawal_whose_broadcast_ends_without_an_answer_is_left_building(session_factory, failure):
"""TX-20 / WDR-16: the transaction may have left; the withdrawal must not be
marked failed with its coins spendable again (the money would go out twice)."""
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
network = FakeNetwork(known=False)
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(
session, _FailingClient(failure), user, _EXTERNAL, 1_000_000_000, network=network
)
assert withdrawal.status == "building"
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
assert [w.status for w in withdrawals] == ["building"]
assert [p.status for p in pendings] == ["building"]
assert utxo.spent_txid == withdrawals[0].txid
assert "withdrawal_broadcast_failed" not in events and "withdrawal_sent" not in events
assert network.asked == []
async def test_a_refused_withdrawal_that_another_server_knows_counts_as_accepted(session_factory):
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
network = FakeNetwork(known=True)
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(
session, _FailingClient(rpc_error("unrecognised")), user, _EXTERNAL, 1_000_000_000, network=network
)
assert withdrawal.status == "broadcast"
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
assert [p.status for p in pendings] == ["pending"] and "withdrawal_sent" in events
assert len(network.fanned_out) == 1
async def test_a_refused_withdrawal_nobody_knows_is_released_and_marked_failed(session_factory):
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(
session,
_FailingClient(rpc_error("min relay fee not met")),
user,
_EXTERNAL,
1_000_000_000,
network=FakeNetwork(known=False),
)
assert excinfo.value.code == "broadcast_failed"
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
assert [w.status for w in withdrawals] == ["failed"] and utxo.spent_txid is None
assert "withdrawal_broadcast_failed" in events
async def test_an_unverifiable_withdrawal_refusal_releases_nothing(session_factory):
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(
session, _FailingClient(rpc_error("x")), user, _EXTERNAL, 1_000_000_000, network=FakeNetwork(known=None)
)
assert withdrawal.status == "building"
async def test_the_withdrawal_route_answers_202_for_an_uncertain_outcome(session_factory):
from contextlib import asynccontextmanager
from app.api.routes.withdrawals import WithdrawalRequest, create_withdrawal
class _Locks:
@asynccontextmanager
async def acquire(self, user_id):
yield
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
network = FakeNetwork()
listener = SimpleNamespace(
client=_FailingClient(ElectrumError("connection closed")),
transaction_known=network.transaction_known,
fan_out_transaction=network.fan_out_transaction,
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=_Locks())))
response = Response()
async with session_factory() as session:
user = await session.get(User, user_id)
body = await create_withdrawal(
WithdrawalRequest(external_address=_EXTERNAL, amount_sats=1_000_000_000), request, response, user=user, session=session
)
assert response.status_code == 202 and body.status == "building"
# --- payouts ---------------------------------------------------------------------
def _payout_entries():
return [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
class _RaisingPayoutClient(FakePayoutClient):
def __init__(self, entries, exc):
super().__init__(entries)
self._exc = exc
async def broadcast(self, raw_tx_hex):
raise self._exc
@pytest.mark.parametrize(
"exc, known",
[
pytest.param(ElectrumError("connection closed"), False, id="uncertain"),
pytest.param(rpc_error("min relay fee not met"), False, id="verified-refusal"),
pytest.param(rpc_error("min relay fee not met"), None, id="unverifiable-refusal"),
],
)
async def test_a_payout_that_is_not_accepted_stays_building_with_a_payout_failed_entry(payout_session_factory, exc, known):
"""TX-05: refusal or uncertainty alike leave the row `building` — the reconciler,
asking the chain, decides whether it left (TX-16, TX-17)."""
await _seed_paying_out_round(payout_session_factory)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(_RaisingPayoutClient(_payout_entries(), exc), known=known))
await scheduler._trigger_payout(1)
async with payout_session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "building"
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "payout_failed" in events and "payout_sent" not in events
async def test_a_payout_refused_as_already_known_is_promoted(payout_session_factory):
"""TX-05: "already known" is an accepted broadcast — `pending` + `payout_sent`."""
await _seed_paying_out_round(payout_session_factory)
listener = FakePayoutListener(_RaisingPayoutClient(_payout_entries(), rpc_error("txn-already-in-mempool")))
await RoundScheduler(payout_session_factory, listener)._trigger_payout(1)
async with payout_session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "pending"
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "payout_sent" in events
assert len(listener.fanned_out) == 1
async def test_an_accepted_payout_is_fanned_out_to_the_other_servers(payout_session_factory):
"""TX-21."""
await _seed_paying_out_round(payout_session_factory)
client = FakePayoutClient(_payout_entries())
listener = FakePayoutListener(client)
await RoundScheduler(payout_session_factory, listener)._trigger_payout(1)
assert listener.fanned_out == client.broadcasted
# --- the verdict itself -------------------------------------------------------------
async def test_the_verdict_distinguishes_the_three_outcomes():
class _Ok:
async def broadcast(self, raw):
return "txid"
ok = await broadcast_with_verdict(_Ok(), FakeNetwork(), "00", "aa")
refused = await broadcast_with_verdict(_FailingClient(rpc_error("bad")), FakeNetwork(known=False), "00", "aa")
uncertain = await broadcast_with_verdict(_FailingClient(ElectrumError("timed out")), FakeNetwork(known=False), "00", "aa")
assert (ok.outcome, refused.outcome, uncertain.outcome) == (
BroadcastOutcome.ACCEPTED,
BroadcastOutcome.REFUSED,
BroadcastOutcome.UNCERTAIN,
)
# --- the listener: transaction_known / fan_out_transaction ----------------------------
_ENDPOINTS = [
ElectrumEndpoint("active.example", 50002, True),
ElectrumEndpoint("first.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50002, True),
]
def _real_tx():
"""A real serialised transaction and its txid — transaction_known checks the
returned bytes hash to the txid asked about."""
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from embit import script
tx = Transaction(
vin=[TransactionInput(bytes.fromhex("11" * 32), 0)],
vout=[TransactionOutput(1000, script.p2wpkh(__import__("embit").ec.PrivateKey(b"\x01" * 32).get_public_key()))],
)
return tx.serialize().hex(), tx.txid().hex()
class _ServerClient:
"""A fake connection to one server: `answer` is a raw tx hex (it knows the tx), the
string "unknown" (an RPC error: no such transaction), or None (unreachable)."""
def __init__(self, answer, log):
self._answer = answer
self._log = log
async def connect(self):
if self._answer is None:
raise ConnectionRefusedError("down")
async def get_transaction(self, txid, verbose=False):
if self._answer == "unknown":
raise rpc_error("No such mempool or blockchain transaction", code=-5)
return self._answer
async def broadcast(self, raw):
self._log.append(raw)
return "txid"
async def close(self):
pass
def _listener(session_factory, answers: dict, active_answer, log=None, allow_no_quorum=False, endpoints=None):
log = log if log is not None else []
listener = ElectrumListener(
lambda endpoint: _ServerClient(answers[endpoint.host], log),
session_factory,
endpoints or _ENDPOINTS,
allow_no_quorum=allow_no_quorum,
)
listener._endpoint_index = 0
listener.client = None if active_answer is Ellipsis else _ServerClient(active_answer, log)
return listener
async def test_transaction_known_is_true_when_any_server_returns_it(session_factory):
raw, txid = _real_tx()
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": raw, "third.example": None},
active_answer="unknown",
)
assert await listener.transaction_known(txid) is True
async def test_transaction_known_is_true_when_only_the_active_server_has_it(session_factory):
raw, txid = _real_tx()
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": "unknown", "third.example": "unknown"},
active_answer=raw,
)
assert await listener.transaction_known(txid) is True
async def test_transaction_known_is_false_only_when_the_active_server_and_a_quorum_deny_it(session_factory):
_, txid = _real_tx()
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": "unknown", "third.example": None},
active_answer="unknown",
)
assert await listener.transaction_known(txid) is False
async def test_transaction_known_is_uncertain_with_too_few_answers(session_factory):
"""Only one of the other servers answered: below MIN_QUORUM_RESPONSES, so the
denial proves nothing."""
_, txid = _real_tx()
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": None, "third.example": None},
active_answer="unknown",
)
assert await listener.transaction_known(txid) is None
async def test_transaction_known_is_uncertain_when_the_active_server_does_not_answer(session_factory):
_, txid = _real_tx()
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": "unknown", "third.example": "unknown"},
active_answer=Ellipsis, # no active connection
)
assert await listener.transaction_known(txid) is None
async def test_transaction_known_ignores_a_reply_that_is_not_the_transaction(session_factory):
"""A server answering with some other transaction does not make this one known —
and does not count as a denial either."""
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": "00", "third.example": "unknown"},
active_answer="unknown",
)
assert await listener.transaction_known("ab" * 32) is False # 2 real denials, 1 ignored reply
async def test_transaction_known_follows_the_reduced_rule_under_allow_no_quorum(session_factory):
_, txid = _real_tx()
single = [ElectrumEndpoint("active.example", 50002, True)]
listener = _listener(session_factory, {}, active_answer="unknown", allow_no_quorum=True, endpoints=single)
assert await listener.transaction_known(txid) is False
strict = _listener(session_factory, {}, active_answer="unknown", endpoints=single)
assert await strict.transaction_known(txid) is None
async def test_fan_out_sends_to_every_other_server_and_never_to_the_active_one(session_factory):
log: list[str] = []
listener = _listener(
session_factory,
{"first.example": "unknown", "second.example": "unknown", "third.example": None},
active_answer="unknown",
log=log,
)
listener.fan_out_transaction("deadbeef")
await asyncio.gather(*listener._background_tasks)
assert log == ["deadbeef", "deadbeef"] # first + second; third unreachable; active untouched