interface: implement support for protocol 1.6
What changed, and why it matters
This commit adds support for a newer Electrum server protocol version (1.6) in the Electrum wallet. It lets the wallet understand two new server response formats for block headers and relay fees, and adds a new way to broadcast groups of transactions. There is no direct evidence in the commit that this fixes an active security vulnerability; it appears to be a feature/compatability update. However, adding protocol support always carries a small risk of parsing mistakes that could be exploited by a malicious server.
Review the new parsing paths for robustness against malformed or malicious server responses, especially the 'headers' list and 'minrelaytxfee' fields. Ensure broadcast_txpackage has appropriate user confirmation and fee checks before production use. Consider adding negative test cases for corrupt responses.
Security signals we found
Protocol version bump and parsing of new server-controlled response formats
New RPC method blockchain.transaction.broadcast_package added without visible rate-limiting or anti-spam controls
Response validation relies on helper assertions; any mismatch in list length vs count raises RequestCorrupted
No explicit security framing or CVE reference in commit message or diff
Evidence from the diff
The patch bumps PROTOCOL_VERSION_MAX from 1.4 to 1.6 and updates Interface to handle protocol 1.6 differences: blockchain.block.headers now returns a list under the ‘headers’ key instead of a concatenated ‘hex’ string; relay fee is fetched via mempool.get_info’s ‘minrelaytxfee’ instead of blockchain.relayfee; and a new broadcast_txpackage method is added for blockchain.transaction.broadcast_package. Validation logic is duplicated/branching by protocol version. The test mock server is updated to advertise 1.6 and return the new response shapes.
Changed components
electrum/interface.pyelectrum/version.pytests/test_interface.pyInspect captured patch +65 / −15
diff --git a/electrum/interface.py b/electrum/interface.py
index a092e35..1e19b8d 100644
--- a/electrum/interface.py
+++ b/electrum/interface.py
@@ -868,13 +868,25 @@ class Interface(Logger):
res = await self.session.send_request('blockchain.block.headers', [start_height, count], timeout=timeout)
# check response
assert_dict_contains_field(res, field_name='count')
- assert_dict_contains_field(res, field_name='hex')
assert_dict_contains_field(res, field_name='max')
assert_non_negative_integer(res['count'])
assert_non_negative_integer(res['max'])
- assert_hex_str(res['hex'])
- if len(res['hex']) != HEADER_SIZE * 2 * res['count']:
- raise RequestCorrupted('inconsistent chunk hex and count')
+ if self.active_protocol_tuple >= (1, 6):
+ hex_headers_list = assert_dict_contains_field(res, field_name='headers')
+ assert_list_or_tuple(hex_headers_list)
+ for item in hex_headers_list:
+ assert_hex_str(item)
+ if len(item) != HEADER_SIZE * 2:
+ raise RequestCorrupted(f"invalid header size. got {len(item)//2}, expected {HEADER_SIZE}")
+ if len(hex_headers_list) != res['count']:
+ raise RequestCorrupted(f"{len(hex_headers_list)=} != {res['count']=}")
+ headers = list(bfh(hex_header) for hex_header in hex_headers_list)
+ else: # proto 1.4
+ hex_headers_concat = assert_dict_contains_field(res, field_name='hex')
+ assert_hex_str(hex_headers_concat)
+ if len(hex_headers_concat) != HEADER_SIZE * 2 * res['count']:
+ raise RequestCorrupted('inconsistent chunk hex and count')
+ headers = list(util.chunks(bfh(hex_headers_concat), size=HEADER_SIZE))
# we never request more than MAX_NUM_HEADERS_IN_REQUEST headers, but we enforce those fit in a single response
if res['max'] < MAX_NUM_HEADERS_PER_REQUEST:
raise RequestCorrupted(f"server uses too low 'max' count for block.headers: {res['max']} < {MAX_NUM_HEADERS_PER_REQUEST}")
@@ -887,7 +899,6 @@ class Interface(Logger):
raise RequestCorrupted(
f"asked for {count} headers but got fewer: {res['count']}. ({start_height=}, {self.tip=})")
# checks done.
- headers = list(util.chunks(bfh(res['hex']), size=HEADER_SIZE))
return headers
async def request_chunk_below_max_checkpoint(
@@ -1405,6 +1416,33 @@ class Interface(Logger):
# 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 broadcast_txpackage(self, txs: Sequence['Transaction']) -> bool:
+ assert self.active_protocol_tuple >= (1, 6), f"server using old protocol: {self.active_protocol_tuple}"
+ rawtxs = [tx.serialize() for tx in txs]
+ assert all(is_hex_str(rawtx) for rawtx in rawtxs)
+ assert all(tx.txid() is not None for tx in txs)
+ timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
+ for tx in txs:
+ if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
+ raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
+ try:
+ res = await self.session.send_request('blockchain.transaction.broadcast_package', [rawtxs], timeout=timeout)
+ except aiorpcx.jsonrpc.CodeMessageError as e:
+ self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. {rawtxs=}")
+ return False
+ success = assert_dict_contains_field(res, field_name='success')
+ if not success:
+ errors = assert_dict_contains_field(res, field_name='errors')
+ self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(errors))}. {rawtxs=}")
+ return False
+ assert success
+ # 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.
+ for tx, rawtx in zip(txs, rawtxs):
+ self._rawtx_cache[tx.txid()] = bytes.fromhex(rawtx)
+ return True
+
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")
@@ -1525,10 +1563,14 @@ class Interface(Logger):
async def get_relay_fee(self) -> int:
"""Returns the min relay feerate in sat/kbyte."""
# do request
- res = await self.session.send_request('blockchain.relayfee')
+ if self.active_protocol_tuple >= (1, 6):
+ res = await self.session.send_request('mempool.get_info')
+ minrelaytxfee = assert_dict_contains_field(res, field_name='minrelaytxfee')
+ else:
+ minrelaytxfee = await self.session.send_request('blockchain.relayfee')
# check response
- assert_non_negative_int_or_float(res)
- relayfee = int(res * bitcoin.COIN)
+ assert_non_negative_int_or_float(minrelaytxfee)
+ relayfee = int(minrelaytxfee * bitcoin.COIN)
relayfee = max(0, relayfee)
return relayfee
diff --git a/electrum/version.py b/electrum/version.py
index 49ad233..18ee702 100644
--- a/electrum/version.py
+++ b/electrum/version.py
@@ -1,7 +1,7 @@
ELECTRUM_VERSION = '4.6.2' # version of the client package
PROTOCOL_VERSION_MIN = '1.4' # electrum protocol
-PROTOCOL_VERSION_MAX = '1.4'
+PROTOCOL_VERSION_MAX = '1.6'
# The hash of the mnemonic seed must begin with this
SEED_PREFIX = '01' # Standard wallet
diff --git a/tests/test_interface.py b/tests/test_interface.py
index 0f9a0e2..3e39c74 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -157,6 +157,7 @@ class ToyServerSession(aiorpcx.RPCSession, Logger):
'blockchain.transaction.get': self._handle_transaction_get,
'blockchain.transaction.broadcast': self._handle_transaction_broadcast,
'blockchain.transaction.get_merkle': self._handle_transaction_get_merkle,
+ 'mempool.get_info': self._handle_mempool_get_info,
'server.ping': self._handle_ping,
}
handler = handlers.get(request.method)
@@ -164,15 +165,15 @@ class ToyServerSession(aiorpcx.RPCSession, Logger):
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_server_version(self, client_name='', protocol_version=None, *args, **kwargs):
+ return ['toy_server/0.1', '1.6']
async def _handle_server_features(self) -> dict:
return {
'genesis_hash': constants.net.GENESIS,
'hosts': {"14.3.140.101": {"tcp_port": 51001, "ssl_port": 51002}},
- 'protocol_max': '1.7.0',
- 'protocol_min': '1.4.3',
+ 'protocol_max': '1.6',
+ 'protocol_min': '1.6',
'pruning': None,
'server_version': 'ElectrumX 1.19.0',
'hash_function': 'sha256',
@@ -181,6 +182,13 @@ class ToyServerSession(aiorpcx.RPCSession, Logger):
async def _handle_estimatefee(self, number, mode=None):
return 0.00001000
+ async def _handle_mempool_get_info(self):
+ return {
+ "mempoolminfee": 0.00001000,
+ "minrelaytxfee": 0.00001000,
+ "incrementalrelayfee": 0.00001000,
+ }
+
def _get_headersub_result(self):
return {'hex': BLOCK_HEADERS[self.cur_height].hex(), 'height': self.cur_height}
@@ -195,8 +203,8 @@ class ToyServerSession(aiorpcx.RPCSession, Logger):
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}
+ headers = list(BLOCK_HEADERS[idx].hex() for idx in range(start_height, last_height+1))
+ return {'headers': headers, 'count': count, 'max': 2016}
async def _handle_ping(self):
return None
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.