Files
plm-lottery/app/tx/outcome.py
T

101 lines
4.0 KiB
Python
Raw Normal View History

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