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

+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