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
+22
-1
@@ -128,6 +128,20 @@ class ElectrumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ElectrumRpcError(ElectrumError):
|
||||
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
|
||||
only failure that says anything about the request itself: a timeout, a dropped
|
||||
connection or a write error (plain ElectrumError) leaves it unknown whether the
|
||||
server ever acted on it. `args[0]` is the error object as the server sent it."""
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
detail = self.args[0] if self.args else ""
|
||||
if isinstance(detail, dict):
|
||||
return str(detail.get("message", detail))
|
||||
return str(detail)
|
||||
|
||||
|
||||
class ElectrumClient:
|
||||
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
|
||||
|
||||
@@ -266,6 +280,13 @@ class ElectrumClient:
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
|
||||
return await self.request("blockchain.transaction.get", [txid, verbose])
|
||||
|
||||
async def get_merkle(self, txid: str, height: int) -> dict:
|
||||
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
|
||||
return await self.request("blockchain.transaction.get_merkle", [txid, height])
|
||||
|
||||
async def get_block_header(self, height: int) -> str:
|
||||
return await self.request("blockchain.block.header", [height])
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""Frame the stream into lines by hand rather than with StreamReader.readline:
|
||||
readline raises on a line over the limit, and that exception used to end this
|
||||
@@ -350,7 +371,7 @@ class ElectrumClient:
|
||||
if future.done():
|
||||
return
|
||||
if message.get("error"):
|
||||
future.set_exception(ElectrumError(message["error"]))
|
||||
future.set_exception(ElectrumRpcError(message["error"]))
|
||||
else:
|
||||
future.set_result(message.get("result"))
|
||||
elif "method" in message:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
@@ -14,7 +15,7 @@ from app.deposits.service import (
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
HeaderValidationError,
|
||||
@@ -403,6 +404,99 @@ class ElectrumListener:
|
||||
agreements = sum(1 for result in responded if agrees(result))
|
||||
return agreements * 2 > len(responded)
|
||||
|
||||
# --- Asking every server (B-76, TX-04, TX-21) ---------------------------------
|
||||
|
||||
def _other_endpoints(self) -> list[ElectrumEndpoint]:
|
||||
return [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
|
||||
def _minimum_other_answers(self) -> int:
|
||||
"""How many *other* servers must answer before their silence or agreement
|
||||
counts as the network's: MIN_QUORUM_RESPONSES, or the reduced development rule
|
||||
of ALLOW_NO_QUORUM (one answer, none if no other server is configured)."""
|
||||
if not self._allow_no_quorum:
|
||||
return MIN_QUORUM_RESPONSES
|
||||
return 1 if self._other_endpoints() else 0
|
||||
|
||||
async def _ask_other_servers(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> list:
|
||||
"""`ask(client)` on a fresh connection to every endpoint but the active one, in
|
||||
parallel, each bounded by _CORROBORATION_TIMEOUT_SECONDS; a server that cannot be
|
||||
reached or does not answer in time yields None. `ask` itself decides what an
|
||||
answer is (it may return None for an unusable one)."""
|
||||
|
||||
async def _one(endpoint: ElectrumEndpoint):
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return list(await asyncio.gather(*(_one(endpoint) for endpoint in self._other_endpoints())))
|
||||
|
||||
async def _ask_active_server(self, ask: Callable[[ElectrumClient], "asyncio.Future"]):
|
||||
client = self.client
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None:
|
||||
"""Does the network know `txid`? Asked of the active server and every other
|
||||
configured one with `blockchain.transaction.get` (B-76, TX-04):
|
||||
|
||||
* True — at least one server returns the transaction: it is on the network,
|
||||
whatever the server that refused it said;
|
||||
* False — every server that answered says it does not know it, and the active
|
||||
server and the minimum number of the others (see _minimum_other_answers) are
|
||||
among them: nobody has it;
|
||||
* None — too few answers to tell. Nothing may be released on this.
|
||||
"""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> bool | None:
|
||||
try:
|
||||
raw = await client.get_transaction(txid, False)
|
||||
except ElectrumRpcError:
|
||||
return False # an explicit "no such transaction"
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw, str) and Transaction.parse(bytes.fromhex(raw)).txid().hex() == txid:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return None # a reply that is not this transaction proves nothing either way
|
||||
|
||||
active = await self._ask_active_server(_ask)
|
||||
others = await self._ask_other_servers(_ask)
|
||||
if active is True or any(answer is True for answer in others):
|
||||
return True
|
||||
answered_others = [answer for answer in others if answer is not None]
|
||||
if active is False and len(answered_others) >= self._minimum_other_answers():
|
||||
return False
|
||||
return None
|
||||
|
||||
def fan_out_transaction(self, raw_tx_hex: str) -> None:
|
||||
"""Send an accepted transaction to every other configured server too (B-76,
|
||||
TX-21): a second route onto the network in case the active server's own relay
|
||||
is weak. Best effort and off the caller's path — a failure is only logged."""
|
||||
|
||||
async def _send() -> None:
|
||||
async def _broadcast(client: ElectrumClient) -> str:
|
||||
return await client.broadcast(raw_tx_hex)
|
||||
|
||||
results = await self._ask_other_servers(_broadcast)
|
||||
logger.info(
|
||||
"fanned a transaction out to %s other server(s), %s answered", len(results), sum(r is not None for r in results)
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_send())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
"""B-28: is `expected_hash` — the header our own active connection
|
||||
reported for `height` — corroborated by other configured servers before
|
||||
|
||||
Reference in new issue
Block a user