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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:22:20 +02:00
1 parent 13cb66cc06
commit 2b6ddb019b
27 files changed
+965 -150

No files matched your search

+13 -7
View File
@@ -12,6 +12,7 @@ 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
@@ -20,6 +21,7 @@ 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 (
@@ -112,7 +114,7 @@ class _Client:
if self._on_broadcast is not None:
await self._on_broadcast()
if self._fail:
raise RuntimeError("mempool conflict")
raise rpc_error("mempool conflict")
return "txid"
@@ -353,7 +355,7 @@ async def test_place_bet_refused_broadcast_with_a_busy_database_leaves_the_rows_
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
await place_bet(session, client, user, network=FakeNetwork(known=False))
finally:
await holder_cm.__aexit__(None, None, None)
@@ -369,7 +371,7 @@ async def test_place_bet_refused_broadcast_still_releases_when_the_database_is_f
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
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
@@ -475,7 +477,9 @@ async def test_withdrawal_refused_broadcast_with_a_busy_database_leaves_the_rows
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)
await request_withdrawal(
session, client, user, _EXTERNAL_ADDRESS, 1_000_000_000, network=FakeNetwork(known=False)
)
finally:
await holder_cm.__aexit__(None, None, None)
@@ -491,7 +495,9 @@ async def test_withdrawal_refused_broadcast_still_releases_when_the_database_is_
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)
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"]
@@ -521,7 +527,7 @@ async def test_bet_route_answers_503_for_service_busy(session_factory, funded):
async with session_factory() as session:
user = await session.get(User, funded)
with pytest.raises(HTTPException) as excinfo:
await create_bet(_fake_request(_Client()), user=user, session=session)
await create_bet(_fake_request(_Client()), Response(), user=user, session=session)
assert excinfo.value.status_code == 503
assert excinfo.value.detail["code"] == "service_busy"
@@ -536,7 +542,7 @@ async def test_withdrawal_route_answers_503_for_service_busy(session_factory, fu
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()), user=user, session=session)
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"