From c2a5d170e8db48ef24e7267c93e36d862f93db0c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 24 Jun 2026 11:37:10 +0200 Subject: [PATCH] Expose negotiated protocol version The v1.6 upgrade negotiated and stored the server protocol version during connection setup, but omitted the documented public API for reading it. Add protocol_version() so callers can reuse the negotiated value. Only fall back to server.version when no cached version exists. Co-Authored-By: HAL 9000 --- src/api.rs | 28 +++++++++++ src/client.rs | 5 ++ src/raw_client.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index aa26da1..2a31b4f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,6 +10,7 @@ use bitcoin::consensus::encode::{deserialize, serialize}; use bitcoin::{block, Script, Transaction, Txid}; use crate::batch::Batch; +use crate::raw_client::{CLIENT_NAME, PROTOCOL_VERSION_MAX, PROTOCOL_VERSION_MIN}; use crate::types::*; impl ElectrumApi for E @@ -181,6 +182,10 @@ where (**self).server_features() } + fn protocol_version(&self) -> Result { + (**self).protocol_version() + } + fn mempool_get_info(&self) -> Result { (**self).mempool_get_info() } @@ -444,6 +449,29 @@ pub trait ElectrumApi { /// Returns the capabilities of the server. fn server_features(&self) -> Result; + /// Returns the negotiated Electrum protocol version. + /// + /// Clients that already negotiated a protocol version during connection setup + /// should return that cached value. Implementors that do not cache it can use + /// this default implementation, which retrieves the version with + /// `server.version`. + fn protocol_version(&self) -> Result { + let version_range = vec![ + PROTOCOL_VERSION_MIN.to_string(), + PROTOCOL_VERSION_MAX.to_string(), + ]; + let result = self.raw_call( + "server.version", + vec![ + Param::String(CLIENT_NAME.to_string()), + Param::StringVec(version_range), + ], + )?; + let response: ServerVersionRes = serde_json::from_value(result)?; + + Ok(response.protocol_version) + } + /// Returns information about the current state of the mempool. /// /// This method was added in protocol v1.6 and replaces `relay_fee` by providing diff --git a/src/client.rs b/src/client.rs index 1f39417..d86880f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -415,6 +415,11 @@ impl ElectrumApi for Client { impl_inner_call!(self, server_features) } + #[inline] + fn protocol_version(&self) -> Result { + impl_inner_call!(self, protocol_version) + } + #[inline] fn mempool_get_info(&self) -> Result { impl_inner_call!(self, mempool_get_info) diff --git a/src/raw_client.rs b/src/raw_client.rs index 0a7a3ff..4068c83 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -640,6 +640,13 @@ impl RawClient { /// /// [`ClientType`]: crate::ClientType fn negotiate_protocol_version(self) -> Result { + let response = self.request_server_version()?; + + self.cache_protocol_version(response.protocol_version)?; + Ok(self) + } + + fn request_server_version(&self) -> Result { let version_range = vec![ PROTOCOL_VERSION_MIN.to_string(), PROTOCOL_VERSION_MAX.to_string(), @@ -653,10 +660,14 @@ impl RawClient { ], ); let result = self.call(req)?; - let response: ServerVersionRes = serde_json::from_value(result)?; - *self.protocol_version.lock()? = Some(response.protocol_version); - Ok(self) + Ok(serde_json::from_value(result)?) + } + + fn cache_protocol_version(&self, protocol_version: String) -> Result { + *self.protocol_version.lock()? = Some(protocol_version.clone()); + + Ok(protocol_version) } fn _reader_thread(&self, until_message: Option) -> Result { @@ -1378,6 +1389,16 @@ impl ElectrumApi for RawClient { Ok(serde_json::from_value(result)?) } + fn protocol_version(&self) -> Result { + if let Some(protocol_version) = self.protocol_version.lock()?.clone() { + return Ok(protocol_version); + } + + let response = self.request_server_version()?; + + self.cache_protocol_version(response.protocol_version) + } + fn mempool_get_info(&self) -> Result { let req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), @@ -1407,7 +1428,11 @@ impl ElectrumApi for RawClient { #[cfg(test)] mod test { - use std::str::FromStr; + use std::{ + io::{self, Cursor, Read, Write}, + str::FromStr, + sync::{Arc, Mutex}, + }; use crate::utils; @@ -1421,6 +1446,60 @@ mod test { // here's an useful list of live servers: https://1209k.com/bitcoin-eye/ele.php. const DEFAULT_TEST_ELECTRUM_SERVER: &str = "fortress.qtornado.com:443"; + #[derive(Clone)] + struct MockStream { + responses: Arc>>>, + requests: Arc>>, + } + + impl MockStream { + fn new(responses: impl Into>) -> Self { + Self { + responses: Arc::new(Mutex::new(Cursor::new(responses.into()))), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + fn written_requests(&self) -> Vec { + let requests = self.requests.lock().unwrap().clone(); + let requests = String::from_utf8(requests).unwrap(); + + requests + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + } + + impl Read for MockStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.responses.lock().unwrap().read(buf) + } + } + + impl Write for MockStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.requests.lock().unwrap().extend_from_slice(buf); + + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn server_version_response(id: usize, protocol_version: &str) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":{id},"result":["ElectrumX 1.18.0","{protocol_version}"]}}"# + ) + "\n" + } + + fn assert_server_version_request(request: &serde_json::Value) { + assert_eq!(request["method"], "server.version"); + assert_eq!(request["params"], serde_json::json!(["", ["1.4", "1.6"]])); + } + fn get_test_auth_client( authorization_provider: Option, ) -> RawClient { @@ -1439,6 +1518,37 @@ mod test { .expect("should build the `RawClient` successfully!") } + #[test] + fn test_protocol_version_returns_negotiated_version_without_new_request() { + let stream = MockStream::new(server_version_response(0, "1.6")); + let stream_handle = stream.clone(); + let client = RawClient::from(stream) + .negotiate_protocol_version() + .unwrap(); + + assert_eq!(client.protocol_version().unwrap(), "1.6"); + assert_eq!(client.calls_made().unwrap(), 1); + + let requests = stream_handle.written_requests(); + assert_eq!(requests.len(), 1); + assert_server_version_request(&requests[0]); + } + + #[test] + fn test_protocol_version_fetches_and_caches_missing_version() { + let stream = MockStream::new(server_version_response(0, "1.6")); + let stream_handle = stream.clone(); + let client = RawClient::from(stream); + + assert_eq!(client.protocol_version().unwrap(), "1.6"); + assert_eq!(client.protocol_version().unwrap(), "1.6"); + assert_eq!(client.calls_made().unwrap(), 1); + + let requests = stream_handle.written_requests(); + assert_eq!(requests.len(), 1); + assert_server_version_request(&requests[0]); + } + #[test] fn test_server_features_simple() { let client = get_test_client();