Files
plm-lottery/tests/unit/test_broadcast_outcome.py
davideandClaude Sonnet 5.5 2b6ddb019b Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.

- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
  carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
  ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
  uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
  blockchain.transaction.get of the active server and every other one (10s each) —
  any server returning the tx means accepted; the active server plus
  MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
  the reduced rule); too few answers is uncertain. "already known" wording is only a
  shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
  releases; uncertain leaves the rows building for the reconciler, publishes SSE and
  POST /bets and POST /withdrawals answer 202 with status building; "already known"
  promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
  (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
  replacement in bump_fee is left for B-77, which rewrites that function.

Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.

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

544 lines
24 KiB
Python

"""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