Files
plm-lottery/app/tx/outcome.py
T
davideandClaude Sonnet 5.5 2b6ddb019b 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>
2026-10-14 14:22:20 +02:00

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