101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
"""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")
|