Skip to content

Commit 186b8ec

Browse files
authored
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
2 parents 9ae716a + 98c4c97 commit 186b8ec

File tree

6 files changed

+653
-241
lines changed

6 files changed

+653
-241
lines changed

electrum/interface.py

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,22 @@
4949

5050
from .util import (ignore_exceptions, log_exceptions, bfh, ESocksProxy,
5151
is_integer, is_non_negative_integer, is_hash256_str, is_hex_str,
52-
is_int_or_float, is_non_negative_int_or_float, OldTaskGroup)
52+
is_int_or_float, is_non_negative_int_or_float, OldTaskGroup,
53+
send_exception_to_crash_reporter, error_text_str_to_safe_str)
5354
from . import util
5455
from . import x509
5556
from . import pem
5657
from . import version
5758
from . import blockchain
5859
from .blockchain import Blockchain, HEADER_SIZE, CHUNK_SIZE
5960
from . import bitcoin
61+
from .bitcoin import DummyAddress, DummyAddressUsedInTxException
6062
from . import constants
6163
from .i18n import _
6264
from .logging import Logger
6365
from .transaction import Transaction
6466
from .fee_policy import FEE_ETA_TARGETS
67+
from .lrucache import LRUCache
6568

6669
if TYPE_CHECKING:
6770
from .network import Network
@@ -279,6 +282,34 @@ class InvalidOptionCombination(Exception): pass
279282
class ConnectError(NetworkException): pass
280283

281284

285+
class TxBroadcastError(NetworkException):
286+
def get_message_for_gui(self):
287+
raise NotImplementedError()
288+
289+
290+
class TxBroadcastHashMismatch(TxBroadcastError):
291+
def get_message_for_gui(self):
292+
return "{}\n{}\n\n{}" \
293+
.format(_("The server returned an unexpected transaction ID when broadcasting the transaction."),
294+
_("Consider trying to connect to a different server, or updating Electrum."),
295+
str(self))
296+
297+
298+
class TxBroadcastServerReturnedError(TxBroadcastError):
299+
def get_message_for_gui(self):
300+
return "{}\n{}\n\n{}" \
301+
.format(_("The server returned an error when broadcasting the transaction."),
302+
_("Consider trying to connect to a different server, or updating Electrum."),
303+
str(self))
304+
305+
306+
class TxBroadcastUnknownError(TxBroadcastError):
307+
def get_message_for_gui(self):
308+
return "{}\n{}" \
309+
.format(_("Unknown error when broadcasting the transaction."),
310+
_("Consider trying to connect to a different server, or updating Electrum."))
311+
312+
282313
class _RSClient(RSClient):
283314
async def create_connection(self):
284315
try:
@@ -499,6 +530,7 @@ def __init__(self, *, network: 'Network', server: ServerAddr):
499530
assert isinstance(server, ServerAddr), f"expected ServerAddr, got {type(server)}"
500531
self.ready = network.asyncio_loop.create_future()
501532
self.got_disconnected = asyncio.Event()
533+
self._blockchain_updated = asyncio.Event()
502534
self.server = server
503535
Logger.__init__(self)
504536
assert network.config.path
@@ -527,6 +559,7 @@ def __init__(self, *, network: 'Network', server: ServerAddr):
527559
self.tip = 0
528560

529561
self._headers_cache = {} # type: Dict[int, bytes]
562+
self._rawtx_cache = LRUCache(maxsize=20) # type: LRUCache[str, bytes] # txid->rawtx
530563

531564
self.fee_estimates_eta = {} # type: Dict[int, int]
532565

@@ -1028,6 +1061,8 @@ async def run_fetch_blocks(self):
10281061
self.logger.info(f"new chain tip. {height=}")
10291062
if blockchain_updated:
10301063
util.trigger_callback('blockchain_updated')
1064+
self._blockchain_updated.set()
1065+
self._blockchain_updated.clear()
10311066
util.trigger_callback('network_updated')
10321067
await self.network.switch_unwanted_fork_interface()
10331068
await self.network.switch_lagging_interface()
@@ -1070,6 +1105,8 @@ async def sync_until(
10701105
continue
10711106
# report progress to gui/etc
10721107
util.trigger_callback('blockchain_updated')
1108+
self._blockchain_updated.set()
1109+
self._blockchain_updated.clear()
10731110
util.trigger_callback('network_updated')
10741111
height += num_headers
10751112
assert height <= next_height+1, (height, self.tip)
@@ -1283,6 +1320,8 @@ async def get_merkle_for_transaction(self, tx_hash: str, tx_height: int) -> dict
12831320
async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:
12841321
if not is_hash256_str(tx_hash):
12851322
raise Exception(f"{repr(tx_hash)} is not a txid")
1323+
if rawtx_bytes := self._rawtx_cache.get(tx_hash):
1324+
return rawtx_bytes.hex()
12861325
raw = await self.session.send_request('blockchain.transaction.get', [tx_hash], timeout=timeout)
12871326
# validate response
12881327
if not is_hex_str(raw):
@@ -1294,8 +1333,40 @@ async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:
12941333
raise RequestCorrupted(f"cannot deserialize received transaction (txid {tx_hash})") from e
12951334
if tx.txid() != tx_hash:
12961335
raise RequestCorrupted(f"received tx does not match expected txid {tx_hash} (got {tx.txid()})")
1336+
self._rawtx_cache[tx_hash] = bytes.fromhex(raw)
12971337
return raw
12981338

1339+
async def broadcast_transaction(self, tx: 'Transaction', *, timeout=None) -> None:
1340+
"""caller should handle TxBroadcastError and RequestTimedOut"""
1341+
txid_calc = tx.txid()
1342+
assert txid_calc is not None
1343+
rawtx = tx.serialize()
1344+
assert is_hex_str(rawtx)
1345+
if timeout is None:
1346+
timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
1347+
if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
1348+
raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
1349+
try:
1350+
out = await self.session.send_request('blockchain.transaction.broadcast', [rawtx], timeout=timeout)
1351+
# note: both 'out' and exception messages are untrusted input from the server
1352+
except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError):
1353+
raise # pass-through
1354+
except aiorpcx.jsonrpc.CodeMessageError as e:
1355+
self.logger.info(f"broadcast_transaction error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
1356+
raise TxBroadcastServerReturnedError(sanitize_tx_broadcast_response(e.message)) from e
1357+
except BaseException as e: # intentional BaseException for sanity!
1358+
self.logger.info(f"broadcast_transaction error2 [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
1359+
send_exception_to_crash_reporter(e)
1360+
raise TxBroadcastUnknownError() from e
1361+
if out != txid_calc:
1362+
self.logger.info(f"unexpected txid for broadcast_transaction [DO NOT TRUST THIS MESSAGE]: "
1363+
f"{error_text_str_to_safe_str(out)} != {txid_calc}. tx={str(tx)}")
1364+
raise TxBroadcastHashMismatch(_("Server returned unexpected transaction ID."))
1365+
# broadcast succeeded.
1366+
# We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
1367+
# the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
1368+
self._rawtx_cache[txid_calc] = bytes.fromhex(rawtx)
1369+
12991370
async def get_history_for_scripthash(self, sh: str) -> List[dict]:
13001371
if not is_hash256_str(sh):
13011372
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:
14621533
raise Exception('bad_header must not check!')
14631534

14641535

1536+
def sanitize_tx_broadcast_response(server_msg) -> str:
1537+
# Unfortunately, bitcoind and hence the Electrum protocol doesn't return a useful error code.
1538+
# So, we use substring matching to grok the error message.
1539+
# server_msg is untrusted input so it should not be shown to the user. see #4968
1540+
server_msg = str(server_msg)
1541+
server_msg = server_msg.replace("\n", r"\n")
1542+
1543+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/script/script_error.cpp
1544+
script_error_messages = {
1545+
r"Script evaluated without error but finished with a false/empty top stack element",
1546+
r"Script failed an OP_VERIFY operation",
1547+
r"Script failed an OP_EQUALVERIFY operation",
1548+
r"Script failed an OP_CHECKMULTISIGVERIFY operation",
1549+
r"Script failed an OP_CHECKSIGVERIFY operation",
1550+
r"Script failed an OP_NUMEQUALVERIFY operation",
1551+
r"Script is too big",
1552+
r"Push value size limit exceeded",
1553+
r"Operation limit exceeded",
1554+
r"Stack size limit exceeded",
1555+
r"Signature count negative or greater than pubkey count",
1556+
r"Pubkey count negative or limit exceeded",
1557+
r"Opcode missing or not understood",
1558+
r"Attempted to use a disabled opcode",
1559+
r"Operation not valid with the current stack size",
1560+
r"Operation not valid with the current altstack size",
1561+
r"OP_RETURN was encountered",
1562+
r"Invalid OP_IF construction",
1563+
r"Negative locktime",
1564+
r"Locktime requirement not satisfied",
1565+
r"Signature hash type missing or not understood",
1566+
r"Non-canonical DER signature",
1567+
r"Data push larger than necessary",
1568+
r"Only push operators allowed in signatures",
1569+
r"Non-canonical signature: S value is unnecessarily high",
1570+
r"Dummy CHECKMULTISIG argument must be zero",
1571+
r"OP_IF/NOTIF argument must be minimal",
1572+
r"Signature must be zero for failed CHECK(MULTI)SIG operation",
1573+
r"NOPx reserved for soft-fork upgrades",
1574+
r"Witness version reserved for soft-fork upgrades",
1575+
r"Taproot version reserved for soft-fork upgrades",
1576+
r"OP_SUCCESSx reserved for soft-fork upgrades",
1577+
r"Public key version reserved for soft-fork upgrades",
1578+
r"Public key is neither compressed or uncompressed",
1579+
r"Stack size must be exactly one after execution",
1580+
r"Extra items left on stack after execution",
1581+
r"Witness program has incorrect length",
1582+
r"Witness program was passed an empty witness",
1583+
r"Witness program hash mismatch",
1584+
r"Witness requires empty scriptSig",
1585+
r"Witness requires only-redeemscript scriptSig",
1586+
r"Witness provided for non-witness script",
1587+
r"Using non-compressed keys in segwit",
1588+
r"Invalid Schnorr signature size",
1589+
r"Invalid Schnorr signature hash type",
1590+
r"Invalid Schnorr signature",
1591+
r"Invalid Taproot control block size",
1592+
r"Too much signature validation relative to witness weight",
1593+
r"OP_CHECKMULTISIG(VERIFY) is not available in tapscript",
1594+
r"OP_IF/NOTIF argument must be minimal in tapscript",
1595+
r"Using OP_CODESEPARATOR in non-witness script",
1596+
r"Signature is found in scriptCode",
1597+
}
1598+
for substring in script_error_messages:
1599+
if substring in server_msg:
1600+
return substring
1601+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/validation.cpp
1602+
# grep "REJECT_"
1603+
# grep "TxValidationResult"
1604+
# should come after script_error.cpp (due to e.g. "non-mandatory-script-verify-flag")
1605+
validation_error_messages = {
1606+
r"coinbase": None,
1607+
r"tx-size-small": None,
1608+
r"non-final": None,
1609+
r"txn-already-in-mempool": None,
1610+
r"txn-mempool-conflict": None,
1611+
r"txn-already-known": None,
1612+
r"non-BIP68-final": None,
1613+
r"bad-txns-nonstandard-inputs": None,
1614+
r"bad-witness-nonstandard": None,
1615+
r"bad-txns-too-many-sigops": None,
1616+
r"mempool min fee not met":
1617+
("mempool min fee not met\n" +
1618+
_("Your transaction is paying a fee that is so low that the bitcoin node cannot "
1619+
"fit it into its mempool. The mempool is already full of hundreds of megabytes "
1620+
"of transactions that all pay higher fees. Try to increase the fee.")),
1621+
r"min relay fee not met": None,
1622+
r"absurdly-high-fee": None,
1623+
r"max-fee-exceeded": None,
1624+
r"too-long-mempool-chain": None,
1625+
r"bad-txns-spends-conflicting-tx": None,
1626+
r"insufficient fee": ("insufficient fee\n" +
1627+
_("Your transaction is trying to replace another one in the mempool but it "
1628+
"does not meet the rules to do so. Try to increase the fee.")),
1629+
r"too many potential replacements": None,
1630+
r"replacement-adds-unconfirmed": None,
1631+
r"mempool full": None,
1632+
r"non-mandatory-script-verify-flag": None,
1633+
r"mandatory-script-verify-flag-failed": None,
1634+
r"Transaction check failed": None,
1635+
}
1636+
for substring in validation_error_messages:
1637+
if substring in server_msg:
1638+
msg = validation_error_messages[substring]
1639+
return msg if msg else substring
1640+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/rpc/rawtransaction.cpp
1641+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/util/error.cpp
1642+
# https://github.com/bitcoin/bitcoin/blob/3f83c744ac28b700090e15b5dda2260724a56f49/src/common/messages.cpp#L126
1643+
# grep "RPC_TRANSACTION"
1644+
# grep "RPC_DESERIALIZATION_ERROR"
1645+
# grep "TransactionError"
1646+
rawtransaction_error_messages = {
1647+
r"Missing inputs": None,
1648+
r"Inputs missing or spent": None,
1649+
r"transaction already in block chain": None,
1650+
r"Transaction already in block chain": None,
1651+
r"Transaction outputs already in utxo set": None,
1652+
r"TX decode failed": None,
1653+
r"Peer-to-peer functionality missing or disabled": None,
1654+
r"Transaction rejected by AcceptToMemoryPool": None,
1655+
r"AcceptToMemoryPool failed": None,
1656+
r"Transaction rejected by mempool": None,
1657+
r"Mempool internal error": None,
1658+
r"Fee exceeds maximum configured by user": None,
1659+
r"Unspendable output exceeds maximum configured by user": None,
1660+
r"Transaction rejected due to invalid package": None,
1661+
}
1662+
for substring in rawtransaction_error_messages:
1663+
if substring in server_msg:
1664+
msg = rawtransaction_error_messages[substring]
1665+
return msg if msg else substring
1666+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/consensus/tx_verify.cpp
1667+
# https://github.com/bitcoin/bitcoin/blob/c7ad94428ab6f54661d7a5441e1fdd0ebf034903/src/consensus/tx_check.cpp
1668+
# grep "REJECT_"
1669+
# grep "TxValidationResult"
1670+
tx_verify_error_messages = {
1671+
r"bad-txns-vin-empty": None,
1672+
r"bad-txns-vout-empty": None,
1673+
r"bad-txns-oversize": None,
1674+
r"bad-txns-vout-negative": None,
1675+
r"bad-txns-vout-toolarge": None,
1676+
r"bad-txns-txouttotal-toolarge": None,
1677+
r"bad-txns-inputs-duplicate": None,
1678+
r"bad-cb-length": None,
1679+
r"bad-txns-prevout-null": None,
1680+
r"bad-txns-inputs-missingorspent":
1681+
("bad-txns-inputs-missingorspent\n" +
1682+
_("You might have a local transaction in your wallet that this transaction "
1683+
"builds on top. You need to either broadcast or remove the local tx.")),
1684+
r"bad-txns-premature-spend-of-coinbase": None,
1685+
r"bad-txns-inputvalues-outofrange": None,
1686+
r"bad-txns-in-belowout": None,
1687+
r"bad-txns-fee-outofrange": None,
1688+
}
1689+
for substring in tx_verify_error_messages:
1690+
if substring in server_msg:
1691+
msg = tx_verify_error_messages[substring]
1692+
return msg if msg else substring
1693+
# https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/policy/policy.cpp
1694+
# grep "reason ="
1695+
# should come after validation.cpp (due to "tx-size" vs "tx-size-small")
1696+
# should come after script_error.cpp (due to e.g. "version")
1697+
policy_error_messages = {
1698+
r"version": _("Transaction uses non-standard version."),
1699+
r"tx-size": _("The transaction was rejected because it is too large (in bytes)."),
1700+
r"scriptsig-size": None,
1701+
r"scriptsig-not-pushonly": None,
1702+
r"scriptpubkey":
1703+
("scriptpubkey\n" +
1704+
_("Some of the outputs pay to a non-standard script.")),
1705+
r"bare-multisig": None,
1706+
r"dust":
1707+
(_("Transaction could not be broadcast due to dust outputs.\n"
1708+
"Some of the outputs are too small in value, probably lower than 1000 satoshis.\n"
1709+
"Check the units, make sure you haven't confused e.g. mBTC and BTC.")),
1710+
r"multi-op-return": _("The transaction was rejected because it contains multiple OP_RETURN outputs."),
1711+
}
1712+
for substring in policy_error_messages:
1713+
if substring in server_msg:
1714+
msg = policy_error_messages[substring]
1715+
return msg if msg else substring
1716+
# otherwise:
1717+
return _("Unknown error")
1718+
1719+
14651720
def check_cert(host, cert):
14661721
try:
14671722
b = pem.dePem(cert, 'CERTIFICATE')

0 commit comments

Comments
 (0)