Merge pull request #10111 from SomberNight/202508_iface_cache_broadcast_tx

interface: don't request same tx from server that we just broadcast to it
This commit is contained in:
ghost43
2025-08-08 17:28:34 +00:00
committed by GitHub
6 changed files with 653 additions and 241 deletions

View File

@@ -49,7 +49,8 @@ import certifi
from .util import (ignore_exceptions, log_exceptions, bfh, ESocksProxy,
is_integer, is_non_negative_integer, is_hash256_str, is_hex_str,
is_int_or_float, is_non_negative_int_or_float, OldTaskGroup)
is_int_or_float, is_non_negative_int_or_float, OldTaskGroup,
send_exception_to_crash_reporter, error_text_str_to_safe_str)
from . import util
from . import x509
from . import pem
@@ -57,11 +58,13 @@ from . import version
from . import blockchain
from .blockchain import Blockchain, HEADER_SIZE, CHUNK_SIZE
from . import bitcoin
from .bitcoin import DummyAddress, DummyAddressUsedInTxException
from . import constants
from .i18n import _
from .logging import Logger
from .transaction import Transaction
from .fee_policy import FEE_ETA_TARGETS
from .lrucache import LRUCache
if TYPE_CHECKING:
from .network import Network
@@ -279,6 +282,34 @@ class InvalidOptionCombination(Exception): pass
class ConnectError(NetworkException): pass
class TxBroadcastError(NetworkException):
def get_message_for_gui(self):
raise NotImplementedError()
class TxBroadcastHashMismatch(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an unexpected transaction ID when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastServerReturnedError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastUnknownError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}" \
.format(_("Unknown error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."))
class _RSClient(RSClient):
async def create_connection(self):
try:
@@ -499,6 +530,7 @@ class Interface(Logger):
assert isinstance(server, ServerAddr), f"expected ServerAddr, got {type(server)}"
self.ready = network.asyncio_loop.create_future()
self.got_disconnected = asyncio.Event()
self._blockchain_updated = asyncio.Event()
self.server = server
Logger.__init__(self)
assert network.config.path
@@ -527,6 +559,7 @@ class Interface(Logger):
self.tip = 0
self._headers_cache = {} # type: Dict[int, bytes]
self._rawtx_cache = LRUCache(maxsize=20) # type: LRUCache[str, bytes] # txid->rawtx
self.fee_estimates_eta = {} # type: Dict[int, int]
@@ -1028,6 +1061,8 @@ class Interface(Logger):
self.logger.info(f"new chain tip. {height=}")
if blockchain_updated:
util.trigger_callback('blockchain_updated')
self._blockchain_updated.set()
self._blockchain_updated.clear()
util.trigger_callback('network_updated')
await self.network.switch_unwanted_fork_interface()
await self.network.switch_lagging_interface()
@@ -1070,6 +1105,8 @@ class Interface(Logger):
continue
# report progress to gui/etc
util.trigger_callback('blockchain_updated')
self._blockchain_updated.set()
self._blockchain_updated.clear()
util.trigger_callback('network_updated')
height += num_headers
assert height <= next_height+1, (height, self.tip)
@@ -1283,6 +1320,8 @@ class Interface(Logger):
async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:
if not is_hash256_str(tx_hash):
raise Exception(f"{repr(tx_hash)} is not a txid")
if rawtx_bytes := self._rawtx_cache.get(tx_hash):
return rawtx_bytes.hex()
raw = await self.session.send_request('blockchain.transaction.get', [tx_hash], timeout=timeout)
# validate response
if not is_hex_str(raw):
@@ -1294,8 +1333,40 @@ class Interface(Logger):
raise RequestCorrupted(f"cannot deserialize received transaction (txid {tx_hash})") from e
if tx.txid() != tx_hash:
raise RequestCorrupted(f"received tx does not match expected txid {tx_hash} (got {tx.txid()})")
self._rawtx_cache[tx_hash] = bytes.fromhex(raw)
return raw
async def broadcast_transaction(self, tx: 'Transaction', *, timeout=None) -> None:
"""caller should handle TxBroadcastError and RequestTimedOut"""
txid_calc = tx.txid()
assert txid_calc is not None
rawtx = tx.serialize()
assert is_hex_str(rawtx)
if timeout is None:
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
try:
out = await self.session.send_request('blockchain.transaction.broadcast', [rawtx], timeout=timeout)
# note: both 'out' and exception messages are untrusted input from the server
except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError):
raise # pass-through
except aiorpcx.jsonrpc.CodeMessageError as e:
self.logger.info(f"broadcast_transaction error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
raise TxBroadcastServerReturnedError(sanitize_tx_broadcast_response(e.message)) from e
except BaseException as e: # intentional BaseException for sanity!
self.logger.info(f"broadcast_transaction error2 [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
send_exception_to_crash_reporter(e)
raise TxBroadcastUnknownError() from e
if out != txid_calc:
self.logger.info(f"unexpected txid for broadcast_transaction [DO NOT TRUST THIS MESSAGE]: "
f"{error_text_str_to_safe_str(out)} != {txid_calc}. tx={str(tx)}")
raise TxBroadcastHashMismatch(_("Server returned unexpected transaction ID."))
# broadcast succeeded.
# We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
# the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
self._rawtx_cache[txid_calc] = bytes.fromhex(rawtx)
async def get_history_for_scripthash(self, sh: str) -> List[dict]:
if not is_hash256_str(sh):
raise Exception(f"{repr(sh)} is not a scripthash")
@@ -1462,6 +1533,190 @@ def _assert_header_does_not_check_against_any_chain(header: dict) -> None:
raise Exception('bad_header must not check!')
def sanitize_tx_broadcast_response(server_msg) -> str:
# Unfortunately, bitcoind and hence the Electrum protocol doesn't return a useful error code.
# So, we use substring matching to grok the error message.
# server_msg is untrusted input so it should not be shown to the user. see #4968
server_msg = str(server_msg)
server_msg = server_msg.replace("\n", r"\n")
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/script/script_error.cpp
script_error_messages = {
r"Script evaluated without error but finished with a false/empty top stack element",
r"Script failed an OP_VERIFY operation",
r"Script failed an OP_EQUALVERIFY operation",
r"Script failed an OP_CHECKMULTISIGVERIFY operation",
r"Script failed an OP_CHECKSIGVERIFY operation",
r"Script failed an OP_NUMEQUALVERIFY operation",
r"Script is too big",
r"Push value size limit exceeded",
r"Operation limit exceeded",
r"Stack size limit exceeded",
r"Signature count negative or greater than pubkey count",
r"Pubkey count negative or limit exceeded",
r"Opcode missing or not understood",
r"Attempted to use a disabled opcode",
r"Operation not valid with the current stack size",
r"Operation not valid with the current altstack size",
r"OP_RETURN was encountered",
r"Invalid OP_IF construction",
r"Negative locktime",
r"Locktime requirement not satisfied",
r"Signature hash type missing or not understood",
r"Non-canonical DER signature",
r"Data push larger than necessary",
r"Only push operators allowed in signatures",
r"Non-canonical signature: S value is unnecessarily high",
r"Dummy CHECKMULTISIG argument must be zero",
r"OP_IF/NOTIF argument must be minimal",
r"Signature must be zero for failed CHECK(MULTI)SIG operation",
r"NOPx reserved for soft-fork upgrades",
r"Witness version reserved for soft-fork upgrades",
r"Taproot version reserved for soft-fork upgrades",
r"OP_SUCCESSx reserved for soft-fork upgrades",
r"Public key version reserved for soft-fork upgrades",
r"Public key is neither compressed or uncompressed",
r"Stack size must be exactly one after execution",
r"Extra items left on stack after execution",
r"Witness program has incorrect length",
r"Witness program was passed an empty witness",
r"Witness program hash mismatch",
r"Witness requires empty scriptSig",
r"Witness requires only-redeemscript scriptSig",
r"Witness provided for non-witness script",
r"Using non-compressed keys in segwit",
r"Invalid Schnorr signature size",
r"Invalid Schnorr signature hash type",
r"Invalid Schnorr signature",
r"Invalid Taproot control block size",
r"Too much signature validation relative to witness weight",
r"OP_CHECKMULTISIG(VERIFY) is not available in tapscript",
r"OP_IF/NOTIF argument must be minimal in tapscript",
r"Using OP_CODESEPARATOR in non-witness script",
r"Signature is found in scriptCode",
}
for substring in script_error_messages:
if substring in server_msg:
return substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/validation.cpp
# grep "REJECT_"
# grep "TxValidationResult"
# should come after script_error.cpp (due to e.g. "non-mandatory-script-verify-flag")
validation_error_messages = {
r"coinbase": None,
r"tx-size-small": None,
r"non-final": None,
r"txn-already-in-mempool": None,
r"txn-mempool-conflict": None,
r"txn-already-known": None,
r"non-BIP68-final": None,
r"bad-txns-nonstandard-inputs": None,
r"bad-witness-nonstandard": None,
r"bad-txns-too-many-sigops": None,
r"mempool min fee not met":
("mempool min fee not met\n" +
_("Your transaction is paying a fee that is so low that the bitcoin node cannot "
"fit it into its mempool. The mempool is already full of hundreds of megabytes "
"of transactions that all pay higher fees. Try to increase the fee.")),
r"min relay fee not met": None,
r"absurdly-high-fee": None,
r"max-fee-exceeded": None,
r"too-long-mempool-chain": None,
r"bad-txns-spends-conflicting-tx": None,
r"insufficient fee": ("insufficient fee\n" +
_("Your transaction is trying to replace another one in the mempool but it "
"does not meet the rules to do so. Try to increase the fee.")),
r"too many potential replacements": None,
r"replacement-adds-unconfirmed": None,
r"mempool full": None,
r"non-mandatory-script-verify-flag": None,
r"mandatory-script-verify-flag-failed": None,
r"Transaction check failed": None,
}
for substring in validation_error_messages:
if substring in server_msg:
msg = validation_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/rpc/rawtransaction.cpp
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/util/error.cpp
# https://github.com/bitcoin/bitcoin/blob/3f83c744ac28b700090e15b5dda2260724a56f49/src/common/messages.cpp#L126
# grep "RPC_TRANSACTION"
# grep "RPC_DESERIALIZATION_ERROR"
# grep "TransactionError"
rawtransaction_error_messages = {
r"Missing inputs": None,
r"Inputs missing or spent": None,
r"transaction already in block chain": None,
r"Transaction already in block chain": None,
r"Transaction outputs already in utxo set": None,
r"TX decode failed": None,
r"Peer-to-peer functionality missing or disabled": None,
r"Transaction rejected by AcceptToMemoryPool": None,
r"AcceptToMemoryPool failed": None,
r"Transaction rejected by mempool": None,
r"Mempool internal error": None,
r"Fee exceeds maximum configured by user": None,
r"Unspendable output exceeds maximum configured by user": None,
r"Transaction rejected due to invalid package": None,
}
for substring in rawtransaction_error_messages:
if substring in server_msg:
msg = rawtransaction_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/consensus/tx_verify.cpp
# https://github.com/bitcoin/bitcoin/blob/c7ad94428ab6f54661d7a5441e1fdd0ebf034903/src/consensus/tx_check.cpp
# grep "REJECT_"
# grep "TxValidationResult"
tx_verify_error_messages = {
r"bad-txns-vin-empty": None,
r"bad-txns-vout-empty": None,
r"bad-txns-oversize": None,
r"bad-txns-vout-negative": None,
r"bad-txns-vout-toolarge": None,
r"bad-txns-txouttotal-toolarge": None,
r"bad-txns-inputs-duplicate": None,
r"bad-cb-length": None,
r"bad-txns-prevout-null": None,
r"bad-txns-inputs-missingorspent":
("bad-txns-inputs-missingorspent\n" +
_("You might have a local transaction in your wallet that this transaction "
"builds on top. You need to either broadcast or remove the local tx.")),
r"bad-txns-premature-spend-of-coinbase": None,
r"bad-txns-inputvalues-outofrange": None,
r"bad-txns-in-belowout": None,
r"bad-txns-fee-outofrange": None,
}
for substring in tx_verify_error_messages:
if substring in server_msg:
msg = tx_verify_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/policy/policy.cpp
# grep "reason ="
# should come after validation.cpp (due to "tx-size" vs "tx-size-small")
# should come after script_error.cpp (due to e.g. "version")
policy_error_messages = {
r"version": _("Transaction uses non-standard version."),
r"tx-size": _("The transaction was rejected because it is too large (in bytes)."),
r"scriptsig-size": None,
r"scriptsig-not-pushonly": None,
r"scriptpubkey":
("scriptpubkey\n" +
_("Some of the outputs pay to a non-standard script.")),
r"bare-multisig": None,
r"dust":
(_("Transaction could not be broadcast due to dust outputs.\n"
"Some of the outputs are too small in value, probably lower than 1000 satoshis.\n"
"Check the units, make sure you haven't confused e.g. mBTC and BTC.")),
r"multi-op-return": _("The transaction was rejected because it contains multiple OP_RETURN outputs."),
}
for substring in policy_error_messages:
if substring in server_msg:
msg = policy_error_messages[substring]
return msg if msg else substring
# otherwise:
return _("Unknown error")
def check_cert(host, cert):
try:
b = pem.dePem(cert, 'CERTIFICATE')

184
electrum/lrucache.py Normal file
View File

@@ -0,0 +1,184 @@
# The MIT License (MIT)
#
# Copyright (c) 2014-2022 Thomas Kemmer
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# -----
#
# This is a stripped down LRU-cache from the "cachetools" library.
# https://github.com/tkem/cachetools/blob/d991ac71b4eb6394be5ec572b835434081393215/src/cachetools/__init__.py
import collections
import collections.abc
from typing import TypeVar, Dict
class _DefaultSize:
__slots__ = ()
def __getitem__(self, _):
return 1
def __setitem__(self, _, value):
assert value == 1
def pop(self, _):
return 1
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")
class Cache(collections.abc.MutableMapping[_KT, _VT]):
"""Mutable mapping to serve as a simple cache or cache base class."""
__marker = object()
__size = _DefaultSize()
def __init__(self, maxsize: int, getsizeof=None):
if getsizeof:
self.getsizeof = getsizeof
if self.getsizeof is not Cache.getsizeof:
self.__size = dict()
self.__data = dict() # type: Dict[_KT, _VT]
self.__currsize = 0
self.__maxsize = maxsize
def __repr__(self):
return "%s(%s, maxsize=%r, currsize=%r)" % (
self.__class__.__name__,
repr(self.__data),
self.__maxsize,
self.__currsize,
)
def __getitem__(self, key: _KT) -> _VT:
try:
return self.__data[key]
except KeyError:
return self.__missing__(key)
def __setitem__(self, key: _KT, value: _VT) -> None:
maxsize = self.__maxsize
size = self.getsizeof(value)
if size > maxsize:
raise ValueError("value too large")
if key not in self.__data or self.__size[key] < size:
while self.__currsize + size > maxsize:
self.popitem()
if key in self.__data:
diffsize = size - self.__size[key]
else:
diffsize = size
self.__data[key] = value
self.__size[key] = size
self.__currsize += diffsize
def __delitem__(self, key: _KT) -> None:
size = self.__size.pop(key)
del self.__data[key]
self.__currsize -= size
def __contains__(self, key: _KT) -> bool:
return key in self.__data
def __missing__(self, key: _KT):
raise KeyError(key)
def __iter__(self):
return iter(self.__data)
def __len__(self):
return len(self.__data)
def get(self, key: _KT, default: _VT = None) -> _VT | None:
if key in self:
return self[key]
else:
return default
def pop(self, key: _KT, default=__marker) -> _VT:
if key in self:
value = self[key]
del self[key]
elif default is self.__marker:
raise KeyError(key)
else:
value = default
return value
def setdefault(self, key: _KT, default: _VT = None) -> _VT | None:
if key in self:
value = self[key]
else:
self[key] = value = default
return value
@property
def maxsize(self) -> int:
"""The maximum size of the cache."""
return self.__maxsize
@property
def currsize(self) -> int:
"""The current size of the cache."""
return self.__currsize
@staticmethod
def getsizeof(value) -> int:
"""Return the size of a cache element's value."""
return 1
class LRUCache(Cache[_KT, _VT]):
"""Least Recently Used (LRU) cache implementation."""
def __init__(self, maxsize: int, getsizeof=None):
Cache.__init__(self, maxsize, getsizeof)
self.__order = collections.OrderedDict()
def __getitem__(self, key: _KT, cache_getitem=Cache.__getitem__) -> _VT | None:
value = cache_getitem(self, key)
if key in self: # __missing__ may not store item
self.__update(key)
return value
def __setitem__(self, key: _KT, value, cache_setitem=Cache.__setitem__) -> None:
cache_setitem(self, key, value)
self.__update(key)
def __delitem__(self, key: _KT, cache_delitem=Cache.__delitem__) -> None:
cache_delitem(self, key)
del self.__order[key]
def popitem(self) -> tuple[_KT, _VT]:
"""Remove and return the `(key, value)` pair least recently used."""
try:
key = next(iter(self.__order))
except StopIteration:
raise KeyError("%s is empty" % type(self).__name__) from None
else:
return (key, self.pop(key))
def __update(self, key: _KT) -> None:
try:
self.__order.move_to_end(key)
except KeyError:
self.__order[key] = None

View File

@@ -43,10 +43,9 @@ from aiohttp import ClientResponse
from . import util
from .util import (
log_exceptions, ignore_exceptions, OldTaskGroup, make_aiohttp_session, send_exception_to_crash_reporter, MyEncoder,
log_exceptions, ignore_exceptions, OldTaskGroup, make_aiohttp_session, MyEncoder,
NetworkRetryManager, error_text_str_to_safe_str, detect_tor_socks_proxy
)
from .bitcoin import DummyAddress, DummyAddressUsedInTxException
from . import constants
from . import blockchain
from . import dns_hacks
@@ -54,7 +53,7 @@ from .transaction import Transaction
from .blockchain import Blockchain
from .interface import (
Interface, PREFERRED_NETWORK_PROTOCOL, RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS,
NetworkException, RequestCorrupted, ServerAddr
NetworkException, RequestCorrupted, ServerAddr, TxBroadcastError,
)
from .version import PROTOCOL_VERSION
from .i18n import _
@@ -287,34 +286,6 @@ class NetworkParameters(NamedTuple):
class BestEffortRequestFailed(NetworkException): pass
class TxBroadcastError(NetworkException):
def get_message_for_gui(self):
raise NotImplementedError()
class TxBroadcastHashMismatch(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an unexpected transaction ID when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastServerReturnedError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}\n\n{}" \
.format(_("The server returned an error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."),
str(self))
class TxBroadcastUnknownError(TxBroadcastError):
def get_message_for_gui(self):
return "{}\n{}" \
.format(_("Unknown error when broadcasting the transaction."),
_("Consider trying to connect to a different server, or updating Electrum."))
class UntrustedServerReturnedError(NetworkException):
def __init__(self, *, original_exception):
self.original_exception = original_exception
@@ -1096,26 +1067,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
"""caller should handle TxBroadcastError"""
if self.interface is None: # handled by best_effort_reliable
raise RequestTimedOut()
if timeout is None:
timeout = self.get_network_timeout_seconds(NetworkTimeout.Urgent)
if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
try:
out = await self.interface.session.send_request('blockchain.transaction.broadcast', [tx.serialize()], timeout=timeout)
# note: both 'out' and exception messages are untrusted input from the server
except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError):
raise # pass-through
except aiorpcx.jsonrpc.CodeMessageError as e:
self.logger.info(f"broadcast_transaction error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
raise TxBroadcastServerReturnedError(self.sanitize_tx_broadcast_response(e.message)) from e
except BaseException as e: # intentional BaseException for sanity!
self.logger.info(f"broadcast_transaction error2 [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
send_exception_to_crash_reporter(e)
raise TxBroadcastUnknownError() from e
if out != tx.txid():
self.logger.info(f"unexpected txid for broadcast_transaction [DO NOT TRUST THIS MESSAGE]: "
f"{error_text_str_to_safe_str(out)} != {tx.txid()}. tx={str(tx)}")
raise TxBroadcastHashMismatch(_("Server returned unexpected transaction ID."))
await self.interface.broadcast_transaction(tx, timeout=timeout)
async def try_broadcasting(self, tx, name) -> bool:
try:
@@ -1127,190 +1079,6 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self.logger.info(f'success: broadcasting {name} {tx.txid()}')
return True
@staticmethod
def sanitize_tx_broadcast_response(server_msg) -> str:
# Unfortunately, bitcoind and hence the Electrum protocol doesn't return a useful error code.
# So, we use substring matching to grok the error message.
# server_msg is untrusted input so it should not be shown to the user. see #4968
server_msg = str(server_msg)
server_msg = server_msg.replace("\n", r"\n")
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/script/script_error.cpp
script_error_messages = {
r"Script evaluated without error but finished with a false/empty top stack element",
r"Script failed an OP_VERIFY operation",
r"Script failed an OP_EQUALVERIFY operation",
r"Script failed an OP_CHECKMULTISIGVERIFY operation",
r"Script failed an OP_CHECKSIGVERIFY operation",
r"Script failed an OP_NUMEQUALVERIFY operation",
r"Script is too big",
r"Push value size limit exceeded",
r"Operation limit exceeded",
r"Stack size limit exceeded",
r"Signature count negative or greater than pubkey count",
r"Pubkey count negative or limit exceeded",
r"Opcode missing or not understood",
r"Attempted to use a disabled opcode",
r"Operation not valid with the current stack size",
r"Operation not valid with the current altstack size",
r"OP_RETURN was encountered",
r"Invalid OP_IF construction",
r"Negative locktime",
r"Locktime requirement not satisfied",
r"Signature hash type missing or not understood",
r"Non-canonical DER signature",
r"Data push larger than necessary",
r"Only push operators allowed in signatures",
r"Non-canonical signature: S value is unnecessarily high",
r"Dummy CHECKMULTISIG argument must be zero",
r"OP_IF/NOTIF argument must be minimal",
r"Signature must be zero for failed CHECK(MULTI)SIG operation",
r"NOPx reserved for soft-fork upgrades",
r"Witness version reserved for soft-fork upgrades",
r"Taproot version reserved for soft-fork upgrades",
r"OP_SUCCESSx reserved for soft-fork upgrades",
r"Public key version reserved for soft-fork upgrades",
r"Public key is neither compressed or uncompressed",
r"Stack size must be exactly one after execution",
r"Extra items left on stack after execution",
r"Witness program has incorrect length",
r"Witness program was passed an empty witness",
r"Witness program hash mismatch",
r"Witness requires empty scriptSig",
r"Witness requires only-redeemscript scriptSig",
r"Witness provided for non-witness script",
r"Using non-compressed keys in segwit",
r"Invalid Schnorr signature size",
r"Invalid Schnorr signature hash type",
r"Invalid Schnorr signature",
r"Invalid Taproot control block size",
r"Too much signature validation relative to witness weight",
r"OP_CHECKMULTISIG(VERIFY) is not available in tapscript",
r"OP_IF/NOTIF argument must be minimal in tapscript",
r"Using OP_CODESEPARATOR in non-witness script",
r"Signature is found in scriptCode",
}
for substring in script_error_messages:
if substring in server_msg:
return substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/validation.cpp
# grep "REJECT_"
# grep "TxValidationResult"
# should come after script_error.cpp (due to e.g. "non-mandatory-script-verify-flag")
validation_error_messages = {
r"coinbase": None,
r"tx-size-small": None,
r"non-final": None,
r"txn-already-in-mempool": None,
r"txn-mempool-conflict": None,
r"txn-already-known": None,
r"non-BIP68-final": None,
r"bad-txns-nonstandard-inputs": None,
r"bad-witness-nonstandard": None,
r"bad-txns-too-many-sigops": None,
r"mempool min fee not met":
("mempool min fee not met\n" +
_("Your transaction is paying a fee that is so low that the bitcoin node cannot "
"fit it into its mempool. The mempool is already full of hundreds of megabytes "
"of transactions that all pay higher fees. Try to increase the fee.")),
r"min relay fee not met": None,
r"absurdly-high-fee": None,
r"max-fee-exceeded": None,
r"too-long-mempool-chain": None,
r"bad-txns-spends-conflicting-tx": None,
r"insufficient fee": ("insufficient fee\n" +
_("Your transaction is trying to replace another one in the mempool but it "
"does not meet the rules to do so. Try to increase the fee.")),
r"too many potential replacements": None,
r"replacement-adds-unconfirmed": None,
r"mempool full": None,
r"non-mandatory-script-verify-flag": None,
r"mandatory-script-verify-flag-failed": None,
r"Transaction check failed": None,
}
for substring in validation_error_messages:
if substring in server_msg:
msg = validation_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/rpc/rawtransaction.cpp
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/util/error.cpp
# https://github.com/bitcoin/bitcoin/blob/3f83c744ac28b700090e15b5dda2260724a56f49/src/common/messages.cpp#L126
# grep "RPC_TRANSACTION"
# grep "RPC_DESERIALIZATION_ERROR"
# grep "TransactionError"
rawtransaction_error_messages = {
r"Missing inputs": None,
r"Inputs missing or spent": None,
r"transaction already in block chain": None,
r"Transaction already in block chain": None,
r"Transaction outputs already in utxo set": None,
r"TX decode failed": None,
r"Peer-to-peer functionality missing or disabled": None,
r"Transaction rejected by AcceptToMemoryPool": None,
r"AcceptToMemoryPool failed": None,
r"Transaction rejected by mempool": None,
r"Mempool internal error": None,
r"Fee exceeds maximum configured by user": None,
r"Unspendable output exceeds maximum configured by user": None,
r"Transaction rejected due to invalid package": None,
}
for substring in rawtransaction_error_messages:
if substring in server_msg:
msg = rawtransaction_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/consensus/tx_verify.cpp
# https://github.com/bitcoin/bitcoin/blob/c7ad94428ab6f54661d7a5441e1fdd0ebf034903/src/consensus/tx_check.cpp
# grep "REJECT_"
# grep "TxValidationResult"
tx_verify_error_messages = {
r"bad-txns-vin-empty": None,
r"bad-txns-vout-empty": None,
r"bad-txns-oversize": None,
r"bad-txns-vout-negative": None,
r"bad-txns-vout-toolarge": None,
r"bad-txns-txouttotal-toolarge": None,
r"bad-txns-inputs-duplicate": None,
r"bad-cb-length": None,
r"bad-txns-prevout-null": None,
r"bad-txns-inputs-missingorspent":
("bad-txns-inputs-missingorspent\n" +
_("You might have a local transaction in your wallet that this transaction "
"builds on top. You need to either broadcast or remove the local tx.")),
r"bad-txns-premature-spend-of-coinbase": None,
r"bad-txns-inputvalues-outofrange": None,
r"bad-txns-in-belowout": None,
r"bad-txns-fee-outofrange": None,
}
for substring in tx_verify_error_messages:
if substring in server_msg:
msg = tx_verify_error_messages[substring]
return msg if msg else substring
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/policy/policy.cpp
# grep "reason ="
# should come after validation.cpp (due to "tx-size" vs "tx-size-small")
# should come after script_error.cpp (due to e.g. "version")
policy_error_messages = {
r"version": _("Transaction uses non-standard version."),
r"tx-size": _("The transaction was rejected because it is too large (in bytes)."),
r"scriptsig-size": None,
r"scriptsig-not-pushonly": None,
r"scriptpubkey":
("scriptpubkey\n" +
_("Some of the outputs pay to a non-standard script.")),
r"bare-multisig": None,
r"dust":
(_("Transaction could not be broadcast due to dust outputs.\n"
"Some of the outputs are too small in value, probably lower than 1000 satoshis.\n"
"Check the units, make sure you haven't confused e.g. mBTC and BTC.")),
r"multi-op-return": _("The transaction was rejected because it contains multiple OP_RETURN outputs."),
}
for substring in policy_error_messages:
if substring in server_msg:
msg = policy_error_messages[substring]
return msg if msg else substring
# otherwise:
return _("Unknown error")
@best_effort_reliable
@catch_server_exceptions
async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:

View File

@@ -30,6 +30,7 @@ class ElectrumTestCase(unittest.IsolatedAsyncioTestCase, Logger):
"""Base class for our unit tests."""
TESTNET = False
REGTEST = False
TEST_ANCHOR_CHANNELS = False
# maxDiff = None # for debugging
@@ -43,19 +44,26 @@ class ElectrumTestCase(unittest.IsolatedAsyncioTestCase, Logger):
@classmethod
def setUpClass(cls):
super().setUpClass()
if cls.TESTNET:
assert not (cls.REGTEST and cls.TESTNET), "regtest and testnet are mutually exclusive"
if cls.REGTEST:
constants.BitcoinRegtest.set_as_network()
elif cls.TESTNET:
constants.BitcoinTestnet.set_as_network()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
if cls.TESTNET:
if cls.TESTNET or cls.REGTEST:
constants.BitcoinMainnet.set_as_network()
def setUp(self):
self._test_lock.acquire()
have_lock = self._test_lock.acquire(timeout=0.1)
if not have_lock:
# This can happen when trying to run the tests in parallel,
# or if a prior test raised during `setUp` or `asyncSetUp` and never released the lock.
raise Exception("timed out waiting for test_lock")
super().setUp()
self.electrum_path = tempfile.mkdtemp()
self.electrum_path = tempfile.mkdtemp(prefix="electrum-unittest-base-")
assert util._asyncio_event_loop is None, "global event loop already set?!"
async def asyncSetUp(self):

View File

@@ -1,4 +1,16 @@
from electrum.interface import ServerAddr
import asyncio
import collections
import aiorpcx
from aiorpcx import RPCError
import electrum
from electrum.interface import ServerAddr, Interface, PaddedRSTransport
from electrum import util, blockchain
from electrum.util import OldTaskGroup, bfh
from electrum.logging import Logger
from electrum.simple_config import SimpleConfig
from electrum.transaction import Transaction
from . import ElectrumTestCase
@@ -46,3 +58,187 @@ class TestServerAddr(ElectrumTestCase):
ServerAddr(host="2400:6180:0:d1::86b:e001", port=50002, protocol="s").to_friendly_name())
self.assertEqual("[2400:6180:0:d1::86b:e001]:50001:t",
ServerAddr(host="2400:6180:0:d1::86b:e001", port=50001, protocol="t").to_friendly_name())
class MockNetwork:
def __init__(self, *, config: SimpleConfig):
self.config = config
self.asyncio_loop = util.get_asyncio_loop()
self.taskgroup = OldTaskGroup()
blockchain.read_blockchains(self.config)
blockchain.init_headers_file_for_best_chain()
self.proxy = None
self.debug = True
self.bhi_lock = asyncio.Lock()
self.interface = None # type: Interface | None
async def connection_down(self, interface: Interface):
pass
def get_network_timeout_seconds(self, request_type) -> int:
return 10
def check_interface_against_healthy_spread_of_connected_servers(self, iface_to_check: Interface) -> bool:
return True
def update_fee_estimates(self, *, fee_est: dict[int, int] = None):
pass
async def switch_unwanted_fork_interface(self):
pass
async def switch_lagging_interface(self):
pass
# regtest chain:
BLOCK_HEADERS = {
0: bfh("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000"),
1: bfh("0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f186c8dfd970a4545f79916bc1d75c9d00432f57c89209bf3bb115b7612848f509c25f45bffff7f2000000000"),
2: bfh("00000020686bdfc6a3db73d5d93e8c9663a720a26ecb1ef20eb05af11b36cdbc57c19f7ebf2cbf153013a1c54abaf70e95198fcef2f3059cc6b4d0f7e876808e7d24d11cc825f45bffff7f2000000000"),
3: bfh("00000020122baa14f3ef54985ae546d1611559e3f487bd2a0f46e8dbb52fbacc9e237972e71019d7feecd9b8596eca9a67032c5f4641b23b5d731dc393e37de7f9c2f299e725f45bffff7f2000000000"),
4: bfh("00000020f8016f7ef3a17d557afe05d4ea7ab6bde1b2247b7643896c1b63d43a1598b747a3586da94c71753f27c075f57f44faf913c31177a0957bbda42e7699e3a2141aed25f45bffff7f2001000000"),
5: bfh("000000201d589c6643c1d121d73b0573e5ee58ab575b8fdf16d507e7e915c5fbfbbfd05e7aee1d692d1615c3bdf52c291032144ce9e3b258a473c17c745047f3431ff8e2ee25f45bffff7f2000000000"),
6: bfh("00000020b833ed46eea01d4c980f59feee44a66aa1162748b6801029565d1466790c405c3a141ce635cbb1cd2b3a4fcdd0a3380517845ba41736c82a79cab535d31128066526f45bffff7f2001000000"),
7: bfh("00000020abe8e119d1877c9dc0dc502d1a253fb9a67967c57732d2f71ee0280e8381ff0a9690c2fe7c1a4450c74dc908fe94dd96c3b0637d51475e9e06a78e944a0c7fe28126f45bffff7f2000000000"),
8: bfh("000000202ce41d94eb70e1518bc1f72523f84a903f9705d967481e324876e1f8cf4d3452148be228a4c3f2061bafe7efdfc4a8d5a94759464b9b5c619994d45dfcaf49e1a126f45bffff7f2000000000"),
9: bfh("00000020552755b6c59f3d51e361d16281842a4e166007799665b5daed86a063dd89857415681cb2d00ff889193f6a68a93f5096aeb2d84ca0af6185a462555822552221a626f45bffff7f2000000000"),
10: bfh("00000020a13a491cbefc93cd1bb1938f19957e22a134faf14c7dee951c45533e2c750f239dc087fc977b06c24a69c682d1afd1020e6dc1f087571ccec66310a786e1548fab26f45bffff7f2000000000"),
11: bfh("00000020dbf3a9b55dfefbaf8b6e43a89cf833fa2e208bbc0c1c5d76c0d71b9e4a65337803b243756c25053253aeda309604363460a3911015929e68705bd89dff6fe064b026f45bffff7f2002000000"),
12: bfh("000000203d0932b3b0c78eccb39a595a28ae4a7c966388648d7783fd1305ec8d40d4fe5fd67cb902a7d807cee7676cb543feec3e053aa824d5dfb528d5b94f9760313d9db726f45bffff7f2001000000"),
}
_active_server_sessions = set()
def _get_active_server_session() -> 'ServerSession':
assert 1 == len(_active_server_sessions), len(_active_server_sessions)
return list(_active_server_sessions)[0]
class ServerSession(aiorpcx.RPCSession, Logger):
def __init__(self, *args, **kwargs):
aiorpcx.RPCSession.__init__(self, *args, **kwargs)
Logger.__init__(self)
self.logger.debug(f'connection from {self.remote_address()}')
self.cur_height = 6 # type: int # chain tip
self.txs = {
"bdae818ad3c1f261317738ae9284159bf54874356f186dbc7afd631dc1527fcb": bfh("020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025100ffffffff0200f2052a010000001600140297bde2689a3c79ffe050583b62f86f2d9dae540000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000"),
} # type: dict[str, bytes]
self._method_counts = collections.defaultdict(int) # type: dict[str, int]
_active_server_sessions.add(self)
async def connection_lost(self):
await super().connection_lost()
self.logger.debug(f'{self.remote_address()} disconnected')
_active_server_sessions.discard(self)
async def handle_request(self, request):
handlers = {
'server.version': self._handle_server_version,
'blockchain.estimatefee': self._handle_estimatefee,
'blockchain.headers.subscribe': self._handle_headers_subscribe,
'blockchain.block.header': self._handle_block_header,
'blockchain.block.headers': self._handle_block_headers,
'blockchain.transaction.get': self._handle_transaction_get,
'blockchain.transaction.broadcast': self._handle_transaction_broadcast,
'server.ping': self._handle_ping,
}
handler = handlers.get(request.method)
self._method_counts[request.method] += 1
coro = aiorpcx.handler_invocation(handler, request)()
return await coro
async def _handle_server_version(self, client_name='', protocol_version=None):
return ['best_server_impl/0.1', '1.4']
async def _handle_estimatefee(self, number, mode=None):
return 1000
async def _handle_headers_subscribe(self):
return {'hex': BLOCK_HEADERS[self.cur_height].hex(), 'height': self.cur_height}
async def _handle_block_header(self, height):
return BLOCK_HEADERS[height].hex()
async def _handle_block_headers(self, start_height, count):
assert start_height <= self.cur_height, (start_height, self.cur_height)
last_height = min(start_height+count-1, self.cur_height) # [start_height, last_height]
count = last_height - start_height + 1
headers = b"".join(BLOCK_HEADERS[idx] for idx in range(start_height, last_height+1))
return {'hex': headers.hex(), 'count': count, 'max': 2016}
async def _handle_ping(self):
return None
async def _handle_transaction_get(self, tx_hash: str, verbose=False):
assert not verbose
rawtx = self.txs.get(tx_hash)
if rawtx is None:
DAEMON_ERROR = 2
raise RPCError(DAEMON_ERROR, f'daemon error: unknown txid={tx_hash}')
return rawtx.hex()
async def _handle_transaction_broadcast(self, raw_tx: str):
tx = Transaction(raw_tx)
self.txs[tx.txid()] = bfh(raw_tx)
return tx.txid()
class TestInterface(ElectrumTestCase):
REGTEST = True
def setUp(self):
super().setUp()
self.config = SimpleConfig({'electrum_path': self.electrum_path})
self._orig_WAIT_FOR_BUFFER_GROWTH_SECONDS = PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS
PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS = 0
def tearDown(self):
PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS = self._orig_WAIT_FOR_BUFFER_GROWTH_SECONDS
super().tearDown()
async def asyncSetUp(self):
await super().asyncSetUp()
self._server: asyncio.base_events.Server = await aiorpcx.serve_rs(ServerSession, "127.0.0.1")
server_socket_addr = self._server.sockets[0].getsockname()
self._server_port = server_socket_addr[1]
self.network = MockNetwork(config=self.config)
async def asyncTearDown(self):
if self.network.interface:
await self.network.interface.close()
self._server.close()
await self._server.wait_closed()
await super().asyncTearDown()
async def _start_iface_and_wait_for_sync(self):
interface = Interface(network=self.network, server=ServerAddr(host="127.0.0.1", port=self._server_port, protocol="t"))
self.network.interface = interface
await util.wait_for2(interface.ready, 5)
await interface._blockchain_updated.wait()
return interface
async def test_client_syncs_headers_to_tip(self):
interface = await self._start_iface_and_wait_for_sync()
self.assertEqual(_get_active_server_session().cur_height, interface.tip)
self.assertFalse(interface.got_disconnected.is_set())
async def test_transaction_get(self):
interface = await self._start_iface_and_wait_for_sync()
# try requesting tx unknown to server:
with self.assertRaises(RPCError) as ctx:
await interface.get_transaction("deadbeef"*8)
self.assertTrue("unknown txid" in ctx.exception.message)
# try requesting known tx:
rawtx = await interface.get_transaction("bdae818ad3c1f261317738ae9284159bf54874356f186dbc7afd631dc1527fcb")
self.assertEqual(rawtx, _get_active_server_session().txs["bdae818ad3c1f261317738ae9284159bf54874356f186dbc7afd631dc1527fcb"].hex())
self.assertEqual(_get_active_server_session()._method_counts["blockchain.transaction.get"], 2)
async def test_transaction_broadcast(self):
interface = await self._start_iface_and_wait_for_sync()
rawtx1 = "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff025200ffffffff0200f2052a010000001600140297bde2689a3c79ffe050583b62f86f2d9dae540000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000"
tx = Transaction(rawtx1)
# broadcast
await interface.broadcast_transaction(tx)
self.assertEqual(bfh(rawtx1), _get_active_server_session().txs.get(tx.txid()))
# now request tx.
# as we just broadcast this same tx, this will hit the client iface cache, and won't call the server.
self.assertEqual(_get_active_server_session()._method_counts["blockchain.transaction.get"], 0)
rawtx2 = await interface.get_transaction(tx.txid())
self.assertEqual(rawtx1, rawtx2)
self.assertEqual(_get_active_server_session()._method_counts["blockchain.transaction.get"], 0)

View File

@@ -102,6 +102,7 @@ class TestLNTransport(ElectrumTestCase):
for t in transports:
t.close()
server.close()
await server.wait_closed()
await f()