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:
1 parent
13cb66cc06
commit
2b6ddb019b
27 files changed
+965
-150
No files matched your search
@@ -0,0 +1,101 @@
|
||||
"""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")
|
||||
Reference in new issue
Block a user