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

+8 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, Request, Response, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,6 +21,7 @@ class BetResponse(BaseModel):
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
async def create_bet(
request: Request,
response: Response,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> BetResponse:
@@ -34,7 +35,7 @@ async def create_bet(
async with request.app.state.user_locks.acquire(user.id):
try:
participant = await place_bet(session, listener.client, user)
participant = await place_bet(session, listener.client, user, network=listener)
except BetError as exc:
# A rejected broadcast isn't the client's fault — it's the network
# refusing our transaction, so it answers 502 rather than 400 (B-07).
@@ -47,6 +48,11 @@ async def create_bet(
)
raise from_api_error(code, exc) from exc
if participant.status == "building":
# B-76 (TX-20, BET-23): the outcome of the broadcast is uncertain — the tx may
# already be on the network — so the bet is accepted for processing, not
# confirmed as sent, and the reconciler settles it.
response.status_code = status.HTTP_202_ACCEPTED
return BetResponse(
round_id=participant.round_id,
bet_txid=participant.bet_txid,
+7 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, Request, Response, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,7 @@ class WithdrawalResponse(BaseModel):
async def create_withdrawal(
body: WithdrawalRequest,
request: Request,
response: Response,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WithdrawalResponse:
@@ -41,7 +42,7 @@ async def create_withdrawal(
async with request.app.state.user_locks.acquire(user.id):
try:
withdrawal = await request_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats
session, listener.client, user, body.external_address, body.amount_sats, network=listener
)
except WithdrawalError as exc:
code = (
@@ -53,6 +54,10 @@ async def create_withdrawal(
)
raise from_api_error(code, exc) from exc
if withdrawal.status == "building":
# B-76 (TX-20, WDR-16): uncertain broadcast — accepted for processing, the
# reconciler decides whether the transaction left.
response.status_code = status.HTTP_202_ACCEPTED
return WithdrawalResponse(
txid=withdrawal.txid,
amount_requested_sats=withdrawal.amount_requested_sats,
+23 -9
View File
@@ -14,6 +14,7 @@ from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
@@ -37,7 +38,12 @@ class _RoundClosedDuringBuild(Exception):
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
async def place_bet(
session: AsyncSession, client: ElectrumClient, user: User, network: TransactionNetwork | None = None
) -> RoundParticipant:
"""`network` (the ElectrumListener) is what verifies a refused broadcast and fans an
accepted one out (B-76); without it a refusal can never be proven, so it never
releases anything."""
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
# balance, the free UTXOs, the participants and the round's status read below are
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
@@ -184,21 +190,29 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
raise _service_busy() from exc
# --- Phase 2: broadcast, then promote both rows to their live state ---------
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
# The node refused it (fee too low, dust, mempool conflict, or simply an
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
# give the user a translatable failure instead of a bare 500 (B-07).
# B-76 (TX-04, TX-20): only a *verified* refusal undoes phase 1. A timeout, a
# dropped connection or a refusal nobody could verify leaves the tx possibly on the
# network, and releasing the coins then sent the bet's sats into the pool with no
# participant behind them — so those rows stay "building" for the reconciler.
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
# The network refused it (fee too low, dust, mempool conflict) and no server
# knows it — nothing is on-chain, so undo phase 1 completely and give the user
# a translatable failure instead of a bare 500 (B-07).
try:
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
await _release_failed_bet(session, participant, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the database is busy, so the release could not be written. The
# rows stay "building" — the state phase 1 committed — and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
raise BetError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning("bet %s: broadcast outcome uncertain (%s), left 'building' for the reconciler", built.txid, verdict.reason)
await session.refresh(participant) # reports the status the DB really holds: building
broadcaster.publish() # an in-flight bet counts towards the round's figures
return participant
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
# rows stay "building" (the tx is on the network) and the reconciler promotes
+22 -1
View File
@@ -128,6 +128,20 @@ class ElectrumError(Exception):
pass
class ElectrumRpcError(ElectrumError):
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
only failure that says anything about the request itself: a timeout, a dropped
connection or a write error (plain ElectrumError) leaves it unknown whether the
server ever acted on it. `args[0]` is the error object as the server sent it."""
@property
def message(self) -> str:
detail = self.args[0] if self.args else ""
if isinstance(detail, dict):
return str(detail.get("message", detail))
return str(detail)
class ElectrumClient:
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
@@ -266,6 +280,13 @@ class ElectrumClient:
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
return await self.request("blockchain.transaction.get", [txid, verbose])
async def get_merkle(self, txid: str, height: int) -> dict:
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
return await self.request("blockchain.transaction.get_merkle", [txid, height])
async def get_block_header(self, height: int) -> str:
return await self.request("blockchain.block.header", [height])
async def _read_loop(self) -> None:
"""Frame the stream into lines by hand rather than with StreamReader.readline:
readline raises on a line over the limit, and that exception used to end this
@@ -350,7 +371,7 @@ class ElectrumClient:
if future.done():
return
if message.get("error"):
future.set_exception(ElectrumError(message["error"]))
future.set_exception(ElectrumRpcError(message["error"]))
else:
future.set_result(message.get("result"))
elif "method" in message:
+95 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
from collections.abc import Callable
from embit.transaction import Transaction
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -14,7 +15,7 @@ from app.deposits.service import (
reinstate_reappeared_utxos,
)
from app.config import MIN_QUORUM_RESPONSES
from app.electrum.client import ElectrumClient, ElectrumEndpoint
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
from app.electrum.scripthash import address_to_scripthash
from app.rounds.draw import (
HeaderValidationError,
@@ -403,6 +404,99 @@ class ElectrumListener:
agreements = sum(1 for result in responded if agrees(result))
return agreements * 2 > len(responded)
# --- Asking every server (B-76, TX-04, TX-21) ---------------------------------
def _other_endpoints(self) -> list[ElectrumEndpoint]:
return [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
def _minimum_other_answers(self) -> int:
"""How many *other* servers must answer before their silence or agreement
counts as the network's: MIN_QUORUM_RESPONSES, or the reduced development rule
of ALLOW_NO_QUORUM (one answer, none if no other server is configured)."""
if not self._allow_no_quorum:
return MIN_QUORUM_RESPONSES
return 1 if self._other_endpoints() else 0
async def _ask_other_servers(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> list:
"""`ask(client)` on a fresh connection to every endpoint but the active one, in
parallel, each bounded by _CORROBORATION_TIMEOUT_SECONDS; a server that cannot be
reached or does not answer in time yields None. `ask` itself decides what an
answer is (it may return None for an unusable one)."""
async def _one(endpoint: ElectrumEndpoint):
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
except Exception:
return None
finally:
await client.close()
return list(await asyncio.gather(*(_one(endpoint) for endpoint in self._other_endpoints())))
async def _ask_active_server(self, ask: Callable[[ElectrumClient], "asyncio.Future"]):
client = self.client
if client is None:
return None
try:
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
except Exception:
return None
async def transaction_known(self, txid: str) -> bool | None:
"""Does the network know `txid`? Asked of the active server and every other
configured one with `blockchain.transaction.get` (B-76, TX-04):
* True — at least one server returns the transaction: it is on the network,
whatever the server that refused it said;
* False — every server that answered says it does not know it, and the active
server and the minimum number of the others (see _minimum_other_answers) are
among them: nobody has it;
* None — too few answers to tell. Nothing may be released on this.
"""
async def _ask(client: ElectrumClient) -> bool | None:
try:
raw = await client.get_transaction(txid, False)
except ElectrumRpcError:
return False # an explicit "no such transaction"
except Exception:
return None
try:
if isinstance(raw, str) and Transaction.parse(bytes.fromhex(raw)).txid().hex() == txid:
return True
except Exception:
pass
return None # a reply that is not this transaction proves nothing either way
active = await self._ask_active_server(_ask)
others = await self._ask_other_servers(_ask)
if active is True or any(answer is True for answer in others):
return True
answered_others = [answer for answer in others if answer is not None]
if active is False and len(answered_others) >= self._minimum_other_answers():
return False
return None
def fan_out_transaction(self, raw_tx_hex: str) -> None:
"""Send an accepted transaction to every other configured server too (B-76,
TX-21): a second route onto the network in case the active server's own relay
is weak. Best effort and off the caller's path — a failure is only logged."""
async def _send() -> None:
async def _broadcast(client: ElectrumClient) -> str:
return await client.broadcast(raw_tx_hex)
results = await self._ask_other_servers(_broadcast)
logger.info(
"fanned a transaction out to %s other server(s), %s answered", len(results), sum(r is not None for r in results)
)
task = asyncio.create_task(_send())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: is `expected_hash` — the header our own active connection
reported for `height` — corroborated by other configured servers before
+9 -7
View File
@@ -17,6 +17,7 @@ from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.wallet.hd import derive_round_pool_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -496,13 +497,14 @@ class RoundScheduler:
pending_id = pending.id
# --- Phase 4: broadcast, then promote the pending row --------------------
try:
await client.broadcast(built.raw_hex)
except Exception:
# The row stays "building": the reconciler will ask the chain about it
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
# of the round being stuck with a payout_txid that never went anywhere.
logger.exception("round %s payout broadcast failed", round_id)
# B-76 (TX-05): the same three outcomes as a bet's broadcast. Accepted — the
# server took it, or refused with "already known" / a server of the network
# holds it — promotes the row; a refusal and an uncertain outcome both leave it
# "building" with a payout_failed entry, since which of the two it really was
# is the reconciler's call, made against the chain (TX-16, TX-17).
verdict = await broadcast_with_verdict(client, self._listener, built.raw_hex, built.txid)
if verdict.outcome is not BroadcastOutcome.ACCEPTED:
logger.error("round %s payout broadcast not accepted (%s): %s", round_id, verdict.outcome.value, verdict.reason)
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
return
+101
View File
@@ -0,0 +1,101 @@
"""What a broadcast attempt amounted to (B-76, TX-04, TX-20, TX-21).
Money may only be released on proof that a transaction never left. The text of an
Electrum error cannot give that proof (servers word "already in the mempool" in many
ways and there is no standard code), and a timeout or a dropped connection says
nothing about whether the server acted before it went quiet. So a broadcast ends in
one of three ways, and only one of them frees coins:
ACCEPTED the server answered with the txid, or refused but at least one server of
the network (the active one or any other) returns the transaction;
REFUSED the server answered with an explicit JSON-RPC error *and* the active
server plus the quorum minimum of the others all say they do not know the
transaction;
UNCERTAIN anything else — a timeout, a dropped connection, a write error, a
refusal whose verification could not get enough answers.
"""
import logging
from enum import Enum
from typing import NamedTuple, Protocol
from app.electrum.client import ElectrumRpcError
logger = logging.getLogger(__name__)
# Messages by which a node says it already has the transaction. Recognising one skips
# the verification below; *not* recognising a message never releases anything, it only
# costs one more round of questions (TX-04).
_ALREADY_KNOWN_MARKERS = (
"already known",
"already in mempool",
"already in the mempool",
"txn-already-known",
"txn-already-in-mempool",
"already in block chain",
"already in blockchain",
)
class BroadcastOutcome(Enum):
ACCEPTED = "accepted"
REFUSED = "refused"
UNCERTAIN = "uncertain"
class BroadcastVerdict(NamedTuple):
outcome: BroadcastOutcome
reason: str # the server's own wording or the failure's, for logs and audit rows
class TransactionNetwork(Protocol):
"""The part of ElectrumListener a broadcast needs (see electrum/listener.py)."""
async def transaction_known(self, txid: str) -> bool | None: ...
def fan_out_transaction(self, raw_tx_hex: str) -> None: ...
def is_already_known(message: str) -> bool:
lowered = message.lower()
return any(marker in lowered for marker in _ALREADY_KNOWN_MARKERS)
async def broadcast_with_verdict(client, network: TransactionNetwork | None, raw_tx_hex: str, txid: str) -> BroadcastVerdict:
"""Broadcast `raw_tx_hex` through the active `client` and say what happened.
With no `network` to ask, an explicit refusal cannot be verified and therefore
comes back UNCERTAIN: the doubt never frees coins. An accepted transaction is
sent to every other server too (best effort, TX-21)."""
try:
await client.broadcast(raw_tx_hex)
except ElectrumRpcError as exc:
reason = exc.message
if is_already_known(reason):
known: bool | None = True
elif network is None:
known = None
else:
known = await network.transaction_known(txid)
if known is True:
_fan_out(network, raw_tx_hex)
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, reason)
if known is False:
return BroadcastVerdict(BroadcastOutcome.REFUSED, reason)
logger.warning("broadcast of %s was refused (%s) but the refusal could not be verified", txid, reason)
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, reason)
except Exception as exc:
# Timeout, dropped connection, write error: the server may have relayed it.
logger.warning("broadcast of %s ended without an answer (%r): outcome uncertain", txid, exc)
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, str(exc) or type(exc).__name__)
_fan_out(network, raw_tx_hex)
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, "")
def _fan_out(network: TransactionNetwork | None, raw_tx_hex: str) -> None:
if network is None:
return
try:
network.fan_out_transaction(raw_tx_hex)
except Exception:
logger.exception("could not start the fan-out of an accepted transaction")
+24 -8
View File
@@ -12,6 +12,7 @@ from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
@@ -31,7 +32,12 @@ class WithdrawalError(ApiError):
async def request_withdrawal(
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
session: AsyncSession,
client: ElectrumClient,
user: User,
external_address: str,
amount_sats: int,
network: TransactionNetwork | None = None,
) -> Withdrawal:
# Checked before anything else: an address from another chain parses fine as a
# witness program (see wallet/address.py), so without this the tx would build,
@@ -162,11 +168,14 @@ async def request_withdrawal(
session.add(pending)
await session.commit()
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a
# drop after the write, or a refusal nobody could verify may mean the transaction
# left: the withdrawal would then be paid out while its row said "failed" and its
# coins were spendable again. Those outcomes leave the rows "building".
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
try:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the release could not be written (database busy). The rows stay
# "building", the state committed before the broadcast, and the
@@ -175,9 +184,16 @@ async def request_withdrawal(
logger.error(
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
)
raise WithdrawalError(
"broadcast_failed", f"the network refused the transaction: {exc}"
) from exc
raise WithdrawalError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning(
"withdrawal %s: broadcast outcome uncertain (%s), left 'building' for the reconciler",
built.txid,
verdict.reason,
)
await session.refresh(withdrawal)
broadcaster.publish()
return withdrawal
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
# "building" and the reconciler promotes them; the caller sees the real status.