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