From 6bd8595ea85e6a3d37750ecda954b15b8001e150 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 10:16:52 +0100 Subject: [PATCH 1/7] feat: Add `server_version` method for protocol version negotiation Add the `server.version` RPC method to the ElectrumApi trait, which is required for protocol v1.6 compliance. According to the v1.6 spec, this must be the first message sent on the wire. - Add `ServerVersionRes` type to parse the `[server_software_version, protocol_version]` response array - Implement `server_version(client_name, protocol_version)` method in `RawClient`, `Client`, and the blanket `Deref` implementation - Add test for the new method Co-Authored-By: Claude Code AI --- src/api.rs | 31 +++++++++++++++++++++++++++++++ src/client.rs | 9 +++++++++ src/raw_client.rs | 29 +++++++++++++++++++++++++++++ src/types.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/src/api.rs b/src/api.rs index 26cd769..c194810 100644 --- a/src/api.rs +++ b/src/api.rs @@ -172,6 +172,14 @@ where (**self).server_features() } + fn server_version( + &self, + client_name: &str, + protocol_version: &str, + ) -> Result { + (**self).server_version(client_name, protocol_version) + } + fn ping(&self) -> Result<(), Error> { (**self).ping() } @@ -398,6 +406,21 @@ pub trait ElectrumApi { /// Returns the capabilities of the server. fn server_features(&self) -> Result; + /// Identifies the client to the server and negotiates the protocol version. + /// + /// According to protocol v1.6, this must be the first message sent on the wire. + /// Only the first `server.version` message is accepted by the server. + /// + /// * `client_name` - A string identifying the connecting client software. + /// * `protocol_version` - The protocol version range supported by the client, as `[min, max]`. + /// + /// Returns the server software version and the negotiated protocol version. + fn server_version( + &self, + client_name: &str, + protocol_version: &str, + ) -> Result; + /// Pings the server. This method can also be used as a "dummy" call to trigger the processing /// of incoming block header or script notifications. fn ping(&self) -> Result<(), Error>; @@ -607,6 +630,14 @@ mod test { unreachable!() } + fn server_version( + &self, + _: &str, + _: &str, + ) -> Result { + unreachable!() + } + fn ping(&self) -> Result<(), super::Error> { unreachable!() } diff --git a/src/client.rs b/src/client.rs index 7e4e626..5f4a1dd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -362,6 +362,15 @@ impl ElectrumApi for Client { impl_inner_call!(self, server_features) } + #[inline] + fn server_version( + &self, + client_name: &str, + protocol_version: &str, + ) -> Result { + impl_inner_call!(self, server_version, client_name, protocol_version) + } + #[inline] fn ping(&self) -> Result<(), Error> { impl_inner_call!(self, ping) diff --git a/src/raw_client.rs b/src/raw_client.rs index 5d67467..1a6aa4c 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -1179,6 +1179,24 @@ impl ElectrumApi for RawClient { Ok(serde_json::from_value(result)?) } + fn server_version( + &self, + client_name: &str, + protocol_version: &str, + ) -> Result { + let req = Request::new_id( + self.last_id.fetch_add(1, Ordering::SeqCst), + "server.version", + vec![ + Param::String(client_name.to_string()), + Param::String(protocol_version.to_string()), + ], + ); + let result = self.call(req)?; + + Ok(serde_json::from_value(result)?) + } + fn ping(&self) -> Result<(), Error> { let req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), @@ -1225,6 +1243,17 @@ mod test { assert_eq!(resp.pruning, None); } + #[test] + fn test_server_version() { + let client = RawClient::new(get_test_server(), None).unwrap(); + + let resp = client + .server_version("rust-electrum-client", "1.4") + .unwrap(); + assert!(!resp.server_software_version.is_empty()); + assert!(!resp.protocol_version.is_empty()); + } + #[test] #[ignore = "depends on a live server"] fn test_batch_response_ordering() { diff --git a/src/types.rs b/src/types.rs index ce3ef9f..17267f9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -202,6 +202,37 @@ pub struct ServerFeaturesRes { pub pruning: Option, } +/// Response to a [`server_version`](../client/struct.Client.html#method.server_version) request. +/// +/// This is returned as an array of two strings: `[server_software_version, protocol_version]`. +#[derive(Clone, Debug)] +pub struct ServerVersionRes { + /// Server software version string (e.g., "ElectrumX 1.18.0"). + pub server_software_version: String, + /// Negotiated protocol version (e.g., "1.6"). + pub protocol_version: String, +} + +impl<'de> Deserialize<'de> for ServerVersionRes { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + let arr: Vec = Vec::deserialize(deserializer)?; + let mut iter = arr.into_iter(); + let server_software_version = iter.next().ok_or_else(|| { + de::Error::custom("expected server_software_version as first element") + })?; + let protocol_version = iter + .next() + .ok_or_else(|| de::Error::custom("expected protocol_version as second element"))?; + Ok(ServerVersionRes { + server_software_version, + protocol_version, + }) + } +} + /// Response to a [`server_features`](../client/struct.Client.html#method.server_features) request. #[derive(Clone, Debug, Deserialize)] pub struct GetHeadersRes { From e51f6d2126dfca3137583d7ff6567a7d6b281f63 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 10:41:30 +0100 Subject: [PATCH 2/7] feat: Add `mempool_get_info` method for v1.6 mempool fee information Add the `mempool.get_info` RPC method to the ElectrumApi trait. This method was added in protocol v1.6 and provides more detailed mempool fee information than the deprecated `blockchain.relayfee` method. The response includes: - `mempoolminfee`: Dynamic minimum fee rate for tx acceptance - `minrelaytxfee`: Static operator-configured minimum relay fee - `incrementalrelayfee`: Minimum fee rate increment for RBF/mempool limiting Co-Authored-By: Claude Code AI --- src/api.rs | 14 ++++++++++++++ src/client.rs | 5 +++++ src/raw_client.rs | 22 ++++++++++++++++++++++ src/types.rs | 15 +++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/src/api.rs b/src/api.rs index c194810..95bfcb4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -172,6 +172,10 @@ where (**self).server_features() } + fn mempool_get_info(&self) -> Result { + (**self).mempool_get_info() + } + fn server_version( &self, client_name: &str, @@ -406,6 +410,12 @@ pub trait ElectrumApi { /// Returns the capabilities of the server. fn server_features(&self) -> Result; + /// Returns information about the current state of the mempool. + /// + /// This method was added in protocol v1.6 and replaces `relay_fee` by providing + /// `minrelaytxfee` along with additional mempool fee information. + fn mempool_get_info(&self) -> Result; + /// Identifies the client to the server and negotiates the protocol version. /// /// According to protocol v1.6, this must be the first message sent on the wire. @@ -630,6 +640,10 @@ mod test { unreachable!() } + fn mempool_get_info(&self) -> Result { + unreachable!() + } + fn server_version( &self, _: &str, diff --git a/src/client.rs b/src/client.rs index 5f4a1dd..4cc8211 100644 --- a/src/client.rs +++ b/src/client.rs @@ -362,6 +362,11 @@ impl ElectrumApi for Client { impl_inner_call!(self, server_features) } + #[inline] + fn mempool_get_info(&self) -> Result { + impl_inner_call!(self, mempool_get_info) + } + #[inline] fn server_version( &self, diff --git a/src/raw_client.rs b/src/raw_client.rs index 1a6aa4c..ee7f6a9 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -1179,6 +1179,17 @@ impl ElectrumApi for RawClient { Ok(serde_json::from_value(result)?) } + fn mempool_get_info(&self) -> Result { + let req = Request::new_id( + self.last_id.fetch_add(1, Ordering::SeqCst), + "mempool.get_info", + vec![], + ); + let result = self.call(req)?; + + Ok(serde_json::from_value(result)?) + } + fn server_version( &self, client_name: &str, @@ -1254,6 +1265,17 @@ mod test { assert!(!resp.protocol_version.is_empty()); } + #[test] + #[ignore = "requires a server supporting protocol v1.6"] + fn test_mempool_get_info() { + let client = RawClient::new(get_test_server(), None).unwrap(); + + let resp = client.mempool_get_info().unwrap(); + assert!(resp.mempoolminfee >= 0.0); + assert!(resp.minrelaytxfee >= 0.0); + assert!(resp.incrementalrelayfee >= 0.0); + } + #[test] #[ignore = "depends on a live server"] fn test_batch_response_ordering() { diff --git a/src/types.rs b/src/types.rs index 17267f9..5ade66e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -202,6 +202,21 @@ pub struct ServerFeaturesRes { pub pruning: Option, } +/// Response to a [`mempool_get_info`](../client/struct.Client.html#method.mempool_get_info) request. +/// +/// Contains information about the current state of the mempool. +#[derive(Clone, Debug, Deserialize)] +pub struct MempoolInfoRes { + /// Dynamic minimum fee rate in BTC/kvB for tx to be accepted given current conditions. + /// The maximum of `minrelaytxfee` and minimum mempool fee. + pub mempoolminfee: f64, + /// Static operator-configurable minimum relay fee for transactions, in BTC/kvB. + pub minrelaytxfee: f64, + /// Static operator-configurable minimum fee rate increment for mempool limiting or + /// replacement, in BTC/kvB. + pub incrementalrelayfee: f64, +} + /// Response to a [`server_version`](../client/struct.Client.html#method.server_version) request. /// /// This is returned as an array of two strings: `[server_software_version, protocol_version]`. From 0356ae76d6729903b137d314e7a1e57775532672 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 10:55:06 +0100 Subject: [PATCH 3/7] feat: Add optional `EstimationMode` parameter to `estimate_fee` Add support for the optional `mode` argument to `blockchain.estimatefee` which was added in protocol v1.6. The mode is passed to bitcoind's `estimatesmartfee` RPC as the `estimate_mode` parameter. - Add `EstimationMode` enum with `Conservative` and `Economical` variants - Update `estimate_fee` to take `Option` as second parameter - Update `Batch::estimate_fee` to support the mode parameter - `batch_estimate_fee` currently always uses `None` for mode This is a breaking change as the `estimate_fee` signature has changed. Co-Authored-By: Claude Code AI --- src/api.rs | 15 +++++++++++---- src/batch.rs | 9 ++++++--- src/client.rs | 4 ++-- src/raw_client.rs | 24 ++++++++++++++++++++---- src/types.rs | 23 +++++++++++++++++++++++ 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/api.rs b/src/api.rs index 95bfcb4..1fadb60 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,8 +42,8 @@ where (**self).block_headers(start_height, count) } - fn estimate_fee(&self, number: usize) -> Result { - (**self).estimate_fee(number) + fn estimate_fee(&self, number: usize, mode: Option) -> Result { + (**self).estimate_fee(number, mode) } fn relay_fee(&self) -> Result { @@ -280,7 +280,10 @@ pub trait ElectrumApi { fn block_headers(&self, start_height: usize, count: usize) -> Result; /// Estimates the fee required in **Bitcoin per kilobyte** to confirm a transaction in `number` blocks. - fn estimate_fee(&self, number: usize) -> Result; + /// + /// Optionally takes an [`EstimationMode`] parameter to specify the fee estimation mode. + /// This parameter was added in protocol v1.6. + fn estimate_fee(&self, number: usize, mode: Option) -> Result; /// Returns the minimum accepted fee by the server's node in **Bitcoin, not Satoshi**. fn relay_fee(&self) -> Result; @@ -482,7 +485,11 @@ mod test { unreachable!() } - fn estimate_fee(&self, _: usize) -> Result { + fn estimate_fee( + &self, + _: usize, + _: Option, + ) -> Result { unreachable!() } diff --git a/src/batch.rs b/src/batch.rs index 75932a5..83a67cd 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -4,7 +4,7 @@ use bitcoin::{Script, Txid}; -use crate::types::{Call, Param, ToElectrumScriptHash}; +use crate::types::{Call, EstimationMode, Param, ToElectrumScriptHash}; /// Helper structure that caches all the requests before they are actually sent to the server. /// @@ -74,8 +74,11 @@ impl Batch { } /// Add one `blockchain.estimatefee` request to the batch queue - pub fn estimate_fee(&mut self, number: usize) { - let params = vec![Param::Usize(number)]; + pub fn estimate_fee(&mut self, number: usize, mode: Option) { + let mut params = vec![Param::Usize(number)]; + if let Some(mode) = mode { + params.push(Param::String(mode.to_string())); + } self.calls .push((String::from("blockchain.estimatefee"), params)); } diff --git a/src/client.rs b/src/client.rs index 4cc8211..9aebf7c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -207,8 +207,8 @@ impl ElectrumApi for Client { } #[inline] - fn estimate_fee(&self, number: usize) -> Result { - impl_inner_call!(self, estimate_fee, number) + fn estimate_fee(&self, number: usize, mode: Option) -> Result { + impl_inner_call!(self, estimate_fee, number, mode) } #[inline] diff --git a/src/raw_client.rs b/src/raw_client.rs index ee7f6a9..ce843ed 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -889,11 +889,15 @@ impl ElectrumApi for RawClient { Ok(deserialized) } - fn estimate_fee(&self, number: usize) -> Result { + fn estimate_fee(&self, number: usize, mode: Option) -> Result { + let mut params = vec![Param::Usize(number)]; + if let Some(mode) = mode { + params.push(Param::String(mode.to_string())); + } let req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), "blockchain.estimatefee", - vec![Param::Usize(number)], + params, ); let result = self.call(req)?; @@ -1098,7 +1102,19 @@ impl ElectrumApi for RawClient { I: IntoIterator + Clone, I::Item: Borrow, { - impl_batch_call!(self, numbers, estimate_fee, apply_deref) + let mut batch = Batch::default(); + for i in numbers { + batch.estimate_fee(*i.borrow(), None); + } + + let resp = self.batch_call(&batch)?; + let mut answer = Vec::new(); + + for x in resp { + answer.push(serde_json::from_value(x)?); + } + + Ok(answer) } fn transaction_broadcast_raw(&self, raw_tx: &[u8]) -> Result { @@ -1307,7 +1323,7 @@ mod test { fn test_estimate_fee() { let client = RawClient::new(get_test_server(), None).unwrap(); - let resp = client.estimate_fee(10).unwrap(); + let resp = client.estimate_fee(10, None).unwrap(); assert!(resp > 0.0); } diff --git a/src/types.rs b/src/types.rs index 5ade66e..df1cdef 100644 --- a/src/types.rs +++ b/src/types.rs @@ -35,6 +35,29 @@ pub enum Param { Bytes(Vec), } +/// Fee estimation mode for [`estimate_fee`](../api/trait.ElectrumApi.html#method.estimate_fee). +/// +/// This parameter was added in protocol v1.6 and is passed to bitcoind's +/// `estimatesmartfee` RPC as the `estimate_mode` parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EstimationMode { + /// A conservative estimate potentially returns a higher feerate and is more likely to be + /// sufficient for the desired target, but is not as responsive to short term drops in the + /// prevailing fee market. + Conservative, + /// Economical fee estimate - potentially lower fees but may take longer to confirm. + Economical, +} + +impl Display for EstimationMode { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + EstimationMode::Conservative => write!(f, "CONSERVATIVE"), + EstimationMode::Economical => write!(f, "ECONOMICAL"), + } + } +} + #[derive(Serialize, Clone)] /// A request that can be sent to the server pub struct Request<'a> { From 52310686e78d1a483fe40fad4a27ae1eb46f4c2c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 11:15:26 +0100 Subject: [PATCH 4/7] feat: Automatically negotiate protocol version on connect Send `server.version` as the first message after establishing a connection, as required by Electrum protocol v1.6. The negotiated protocol version is stored in `RawClient` and can be retrieved via the new `protocol_version()` method. - Add `CLIENT_NAME`, `PROTOCOL_VERSION_MIN`, `PROTOCOL_VERSION_MAX` constants - Add `protocol_version` field to `RawClient` to store negotiated version - All constructors (`new`, `new_ssl`, `new_ssl_from_stream`, `new_proxy`) now automatically call `server.version` after connecting - The `server_version()` method also updates the stored version when called This is a breaking change as the constructors now return errors if protocol negotiation fails. Co-Authored-By: Claude Code AI --- src/raw_client.rs | 65 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/raw_client.rs b/src/raw_client.rs index ce843ed..43ce3e4 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -45,6 +45,15 @@ use crate::api::ElectrumApi; use crate::batch::Batch; use crate::types::*; +/// Client name sent to the server during protocol version negotiation. +pub const CLIENT_NAME: &str = "rust-electrum-client"; + +/// Minimum protocol version supported by this client. +pub const PROTOCOL_VERSION_MIN: &str = "1.4"; + +/// Maximum protocol version supported by this client. +pub const PROTOCOL_VERSION_MAX: &str = "1.6"; + macro_rules! impl_batch_call { ( $self:expr, $data:expr, $call:ident ) => {{ impl_batch_call!($self, $data, $call, ) @@ -142,6 +151,9 @@ where headers: Mutex>, script_notifications: Mutex>>, + /// The protocol version negotiated with the server via `server.version`. + protocol_version: Mutex>, + #[cfg(feature = "debug-calls")] calls: AtomicUsize, } @@ -163,6 +175,8 @@ where headers: Mutex::new(VecDeque::new()), script_notifications: Mutex::new(HashMap::new()), + protocol_version: Mutex::new(None), + #[cfg(feature = "debug-calls")] calls: AtomicUsize::new(0), } @@ -173,6 +187,9 @@ where pub type ElectrumPlaintextStream = TcpStream; impl RawClient { /// Creates a new plaintext client and tries to connect to `socket_addr`. + /// + /// Automatically negotiates the protocol version with the server using + /// `server.version` as required by the Electrum protocol. pub fn new( socket_addrs: A, timeout: Option, @@ -187,7 +204,9 @@ impl RawClient { None => TcpStream::connect(socket_addrs)?, }; - Ok(stream.into()) + let client: Self = stream.into(); + client.negotiate_protocol_version()?; + Ok(client) } } @@ -285,7 +304,9 @@ impl RawClient { .connect(&domain, stream) .map_err(Error::SslHandshakeError)?; - Ok(stream.into()) + let client: Self = stream.into(); + client.negotiate_protocol_version()?; + Ok(client) } } @@ -466,7 +487,9 @@ impl RawClient { .map_err(Error::CouldNotCreateConnection)?; let stream = StreamOwned::new(session, tcp_stream); - Ok(stream.into()) + let client: Self = stream.into(); + client.negotiate_protocol_version()?; + Ok(client) } } @@ -496,7 +519,9 @@ impl RawClient { stream.get_mut().set_read_timeout(timeout)?; stream.get_mut().set_write_timeout(timeout)?; - Ok(stream.into()) + let client: Self = stream.into(); + client.negotiate_protocol_version()?; + Ok(client) } #[cfg(any( @@ -551,6 +576,32 @@ impl RawClient { // self._reader_thread(None).map(|_| ()) // } + /// Negotiates the protocol version with the server. + /// + /// This sends `server.version` as the first message and stores the negotiated + /// protocol version. Called automatically by constructors. + fn negotiate_protocol_version(&self) -> Result<(), Error> { + let version_range = format!("[{}, {}]", PROTOCOL_VERSION_MIN, PROTOCOL_VERSION_MAX); + let req = Request::new_id( + self.last_id.fetch_add(1, Ordering::SeqCst), + "server.version", + vec![ + Param::String(CLIENT_NAME.to_string()), + Param::String(version_range), + ], + ); + let result = self.call(req)?; + let response: ServerVersionRes = serde_json::from_value(result)?; + + *self.protocol_version.lock()? = Some(response.protocol_version); + Ok(()) + } + + /// Returns the protocol version negotiated with the server, if available. + pub fn protocol_version(&self) -> Result, Error> { + Ok(self.protocol_version.lock()?.clone()) + } + fn _reader_thread(&self, until_message: Option) -> Result { let mut raw_resp = String::new(); let resp = match self.buf_reader.try_lock() { @@ -1220,8 +1271,12 @@ impl ElectrumApi for RawClient { ], ); let result = self.call(req)?; + let response: ServerVersionRes = serde_json::from_value(result)?; - Ok(serde_json::from_value(result)?) + // Store the negotiated protocol version + *self.protocol_version.lock()? = Some(response.protocol_version.clone()); + + Ok(response) } fn ping(&self) -> Result<(), Error> { From 8f6bda073d487138d44fb8bdbd2e2c5078639281 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 11:30:41 +0100 Subject: [PATCH 5/7] feat: Support v1.6 `block_headers` response format with backwards compatibility Parse `blockchain.block.headers` response based on negotiated protocol version: - v1.6+: Headers returned as array of hex strings in `headers` field - v1.4 (legacy): Headers returned as concatenated hex in `hex` field - Add `is_protocol_version_at_least()` helper for semantic version comparison - Rename old response type to `GetHeadersResLegacy` (internal) - New `GetHeadersRes` handles v1.6 format with `header_hexes` field - Both formats produce the same public `headers: Vec` output Co-Authored-By: Claude Code AI --- src/raw_client.rs | 51 ++++++++++++++++++++++++++++++++++++++--------- src/types.rs | 22 ++++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/raw_client.rs b/src/raw_client.rs index 43ce3e4..8abed2e 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -54,6 +54,17 @@ pub const PROTOCOL_VERSION_MIN: &str = "1.4"; /// Maximum protocol version supported by this client. pub const PROTOCOL_VERSION_MAX: &str = "1.6"; +/// Checks if a protocol version string is at least the specified major.minor version. +fn is_protocol_version_at_least(version: &str, major: u32, minor: u32) -> bool { + let mut parts = version.split('.'); + let v_major = parts.next().and_then(|s| s.parse::().ok()); + let v_minor = parts.next().and_then(|s| s.parse::().ok()); + match (v_major, v_minor) { + (Some(v_major), Some(v_minor)) => v_major > major || (v_major == major && v_minor >= minor), + _ => false, + } +} + macro_rules! impl_batch_call { ( $self:expr, $data:expr, $call:ident ) => {{ impl_batch_call!($self, $data, $call, ) @@ -928,16 +939,38 @@ impl ElectrumApi for RawClient { ); let result = self.call(req)?; - let mut deserialized: GetHeadersRes = serde_json::from_value(result)?; - for i in 0..deserialized.count { - let (start, end) = (i * 80, (i + 1) * 80); - deserialized - .headers - .push(deserialize(&deserialized.raw_headers[start..end])?); - } - deserialized.raw_headers.clear(); + // Check protocol version to determine response format + let is_v1_6_or_later = { + let protocol_version = self.protocol_version.lock()?; + protocol_version + .as_ref() + .map(|v| is_protocol_version_at_least(v, 1, 6)) + .unwrap_or(false) + }; - Ok(deserialized) + if is_v1_6_or_later { + // v1.6+: headers field contains array of hex strings + let mut deserialized: GetHeadersRes = serde_json::from_value(result)?; + for header_hex in &deserialized.header_hexes { + let header_bytes = Vec::::from_hex(header_hex)?; + deserialized.headers.push(deserialize(&header_bytes)?); + } + Ok(deserialized) + } else { + // v1.4: hex field contains concatenated headers + let deserialized: GetHeadersResLegacy = serde_json::from_value(result)?; + let mut headers = Vec::new(); + for i in 0..deserialized.count { + let (start, end) = (i * 80, (i + 1) * 80); + headers.push(deserialize(&deserialized.raw_headers[start..end])?); + } + Ok(GetHeadersRes { + max: deserialized.max, + count: deserialized.count, + header_hexes: Vec::new(), + headers, + }) + } } fn estimate_fee(&self, number: usize, mode: Option) -> Result { diff --git a/src/types.rs b/src/types.rs index df1cdef..8f1fe99 100644 --- a/src/types.rs +++ b/src/types.rs @@ -271,17 +271,31 @@ impl<'de> Deserialize<'de> for ServerVersionRes { } } -/// Response to a [`server_features`](../client/struct.Client.html#method.server_features) request. +/// Response to a [`block_headers`](../client/struct.Client.html#method.block_headers) request (protocol v1.4, legacy format). +/// +/// In protocol v1.4, the headers are returned as a single concatenated hex string. #[derive(Clone, Debug, Deserialize)] -pub struct GetHeadersRes { +pub(crate) struct GetHeadersResLegacy { /// Maximum number of headers returned in a single response. pub max: usize, /// Number of headers in this response. pub count: usize, - /// Raw headers concatenated. Normally cleared before returning. + /// Raw headers concatenated. #[serde(rename(deserialize = "hex"), deserialize_with = "from_hex")] pub raw_headers: Vec, - /// Array of block headers. +} + +/// Response to a [`block_headers`](../client/struct.Client.html#method.block_headers) request. +#[derive(Clone, Debug, Deserialize)] +pub struct GetHeadersRes { + /// Maximum number of headers returned in a single response. + pub max: usize, + /// Number of headers in this response. + pub count: usize, + /// Array of header hex strings (v1.6 format). + #[serde(default, rename(deserialize = "headers"))] + pub(crate) header_hexes: Vec, + /// Array of block headers (populated after parsing). #[serde(skip)] pub headers: Vec, } From dfd40b3d96afc9280a57fe10bd68c1e4d9e7d38c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 11:55:26 +0100 Subject: [PATCH 6/7] feat: Add `transaction_broadcast_package` method for package relay Add support for the `blockchain.transaction.broadcast_package` RPC method introduced in protocol v1.6 for package relay (CPFP) support. - Add `BroadcastPackageRes` and `BroadcastPackageError` types - Add `Param::StringVec` variant for array parameters - Add `transaction_broadcast_package_raw` trait method with generic `AsRef<[u8]>` - Add `transaction_broadcast_package` convenience method that serializes transactions Co-Authored-By: Claude Code AI --- src/api.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/client.rs | 8 ++++++++ src/raw_client.rs | 30 ++++++++++++++++++++++++++++++ src/types.rs | 25 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/src/api.rs b/src/api.rs index 1fadb60..378fbf3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -141,6 +141,13 @@ where (**self).transaction_broadcast_raw(raw_tx) } + fn transaction_broadcast_package_raw>( + &self, + raw_txs: &[T], + ) -> Result { + (**self).transaction_broadcast_package_raw(raw_txs) + } + fn transaction_get_merkle(&self, txid: &Txid, height: usize) -> Result { (**self).transaction_get_merkle(txid, height) } @@ -253,6 +260,21 @@ pub trait ElectrumApi { self.transaction_broadcast_raw(&buffer) } + /// Broadcasts a package of transactions to the network. + /// + /// The package must consist of a child with its parents, where none of the parents + /// depend on one another. The package must be topologically sorted, with the child + /// being the last element in the array. + /// + /// This method was added in protocol v1.6 for package relay support. + fn transaction_broadcast_package( + &self, + txs: &[Transaction], + ) -> Result { + let raw_txs: Vec> = txs.iter().map(serialize).collect(); + self.transaction_broadcast_package_raw(&raw_txs) + } + /// Executes the requested API call returning the raw answer. fn raw_call( &self, @@ -385,6 +407,18 @@ pub trait ElectrumApi { /// Broadcasts the raw bytes of a transaction to the network. fn transaction_broadcast_raw(&self, raw_tx: &[u8]) -> Result; + /// Broadcasts a package of raw transactions to the network. + /// + /// The package must consist of a child with its parents, where none of the parents + /// depend on one another. The package must be topologically sorted, with the child + /// being the last element in the array. + /// + /// This method was added in protocol v1.6 for package relay support. + fn transaction_broadcast_package_raw>( + &self, + raw_txs: &[T], + ) -> Result; + /// Returns the merkle path for the transaction `txid` confirmed in the block at `height`. fn transaction_get_merkle(&self, txid: &Txid, height: usize) -> Result; @@ -612,6 +646,13 @@ mod test { unreachable!() } + fn transaction_broadcast_package_raw>( + &self, + _: &[T], + ) -> Result { + unreachable!() + } + fn transaction_get_merkle( &self, _: &bitcoin::Txid, diff --git a/src/client.rs b/src/client.rs index 9aebf7c..c8bb78f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -322,6 +322,14 @@ impl ElectrumApi for Client { impl_inner_call!(self, transaction_broadcast_raw, raw_tx) } + #[inline] + fn transaction_broadcast_package_raw>( + &self, + raw_txs: &[T], + ) -> Result { + impl_inner_call!(self, transaction_broadcast_package_raw, raw_txs) + } + #[inline] fn transaction_get_merkle(&self, txid: &Txid, height: usize) -> Result { impl_inner_call!(self, transaction_get_merkle, txid, height) diff --git a/src/raw_client.rs b/src/raw_client.rs index 8abed2e..5acd834 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -1213,6 +1213,25 @@ impl ElectrumApi for RawClient { Ok(serde_json::from_value(result)?) } + fn transaction_broadcast_package_raw>( + &self, + raw_txs: &[Tx], + ) -> Result { + let hex_txs: Vec = raw_txs + .iter() + .map(|tx| tx.as_ref().to_lower_hex_string()) + .collect(); + let params = vec![Param::StringVec(hex_txs)]; + let req = Request::new_id( + self.last_id.fetch_add(1, Ordering::SeqCst), + "blockchain.transaction.broadcast_package", + params, + ); + let result = self.call(req)?; + + Ok(serde_json::from_value(result)?) + } + fn transaction_get_merkle(&self, txid: &Txid, height: usize) -> Result { let params = vec![Param::String(format!("{:x}", txid)), Param::Usize(height)]; let req = Request::new_id( @@ -1380,6 +1399,17 @@ mod test { assert!(resp.incrementalrelayfee >= 0.0); } + #[test] + #[ignore = "requires a server supporting protocol v1.6 and valid transactions"] + fn test_transaction_broadcast_package() { + let client = RawClient::new(get_test_server(), None).unwrap(); + + // Empty package should return an error or unsuccessful response + let resp = client.transaction_broadcast_package_raw::>(&[]); + // The server may reject an empty package with a protocol error + assert!(resp.is_err() || !resp.unwrap().success); + } + #[test] #[ignore = "depends on a live server"] fn test_batch_response_ordering() { diff --git a/src/types.rs b/src/types.rs index 8f1fe99..663c249 100644 --- a/src/types.rs +++ b/src/types.rs @@ -33,6 +33,8 @@ pub enum Param { Bool(bool), /// Bytes array parameter Bytes(Vec), + /// String array parameter + StringVec(Vec), } /// Fee estimation mode for [`estimate_fee`](../api/trait.ElectrumApi.html#method.estimate_fee). @@ -334,6 +336,29 @@ pub struct TxidFromPosRes { pub merkle: Vec<[u8; 32]>, } +/// Error details for a transaction that failed to broadcast in a package. +#[derive(Clone, Debug, Deserialize)] +pub struct BroadcastPackageError { + /// The txid of the transaction that failed. + pub txid: Txid, + /// The error message describing why the transaction was rejected. + pub error: String, +} + +/// Response to a [`transaction_broadcast_package`](../client/struct.Client.html#method.transaction_broadcast_package) +/// request. +/// +/// This method was added in protocol v1.6 for package relay support. +#[derive(Clone, Debug, Deserialize)] +pub struct BroadcastPackageRes { + /// Whether the package was successfully accepted by the mempool. + pub success: bool, + /// List of errors for transactions that were rejected. + /// Only present if some transactions failed. + #[serde(default)] + pub errors: Vec, +} + /// Notification of a new block header #[derive(Clone, Debug, Deserialize)] pub struct HeaderNotification { From 93c20f9f458973c88ebc904b79779b980f4d00ba Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 6 Jan 2026 11:58:03 +0100 Subject: [PATCH 7/7] docs: Mark `relay_fee` as deprecated in protocol v1.6+ Add documentation note that `relay_fee` is deprecated in protocol v1.6 and users should use `mempool_get_info` instead. Co-Authored-By: Claude Code AI --- src/api.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api.rs b/src/api.rs index 378fbf3..a6d2fae 100644 --- a/src/api.rs +++ b/src/api.rs @@ -308,6 +308,9 @@ pub trait ElectrumApi { fn estimate_fee(&self, number: usize, mode: Option) -> Result; /// Returns the minimum accepted fee by the server's node in **Bitcoin, not Satoshi**. + /// + /// **Note:** This method is deprecated in protocol v1.6+. Use [`mempool_get_info`](#method.mempool_get_info) + /// instead, which provides `minrelaytxfee` along with additional mempool fee information. fn relay_fee(&self) -> Result; /// Subscribes to notifications for activity on a specific *scriptPubKey*.