From 581156deddbb634b2c170a718354d7b8d78cef08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 6 May 2026 16:19:22 +0000 Subject: [PATCH 1/2] feat!: Add Electrum protocol v1.6 method support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `ServerVersion` request type (`server.version`) - Add optional `mode` parameter to `EstimateFee` (breaking: new field) - Support both pre-1.6 (concatenated hex) and v1.6 (list of hex strings) response formats for `blockchain.block.headers` - Add `BroadcastPackage` request type (`blockchain.transaction.broadcast_package`) - Add `GetMempoolInfo` request type (`mempool.get_info`) - Add missing `Features` to `gen_pending_request_types!` macro Closes bitcoindevkit#8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/custom_serde.rs | 45 +++++++++++---- src/pending_request.rs | 4 ++ src/request.rs | 124 ++++++++++++++++++++++++++++++++++++++++- src/response.rs | 63 +++++++++++++++++++-- 4 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/custom_serde.rs b/src/custom_serde.rs index 722fffb..1102f97 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -19,23 +19,44 @@ where deserialize_hex(&hex_str).map_err(serde::de::Error::custom) } -pub fn from_cancat_consensus_hex<'de, T, D>(deserializer: D) -> Result, D::Error> +/// Deserializes headers from either: +/// - A single concatenated hex string (pre-1.6: `"hex"` field) +/// - An array of individual hex strings (v1.6+: `"headers"` field) +pub fn headers_from_hex_or_list<'de, T, D>(deserializer: D) -> Result, D::Error> where T: bitcoin::consensus::encode::Decodable, D: Deserializer<'de>, { - let hex_str = String::deserialize(deserializer)?; - let data = Vec::::from_hex(&hex_str).map_err(serde::de::Error::custom)?; - - let mut items = Vec::::new(); - let mut read_start = 0_usize; - while read_start < data.len() { - let (item, read_count) = - deserialize_partial::(&data[read_start..]).map_err(serde::de::Error::custom)?; - read_start += read_count; - items.push(item); + let value = Value::deserialize(deserializer)?; + match value { + Value::String(hex_str) => { + // Pre-1.6: single concatenated hex string + let data = Vec::::from_hex(&hex_str).map_err(serde::de::Error::custom)?; + let mut items = Vec::::new(); + let mut read_start = 0_usize; + while read_start < data.len() { + let (item, read_count) = deserialize_partial::(&data[read_start..]) + .map_err(serde::de::Error::custom)?; + read_start += read_count; + items.push(item); + } + Ok(items) + } + Value::Array(arr) => { + // v1.6: array of hex strings + arr.into_iter() + .map(|v| { + let hex_str = v.as_str().ok_or_else(|| { + serde::de::Error::custom("expected hex string in headers array") + })?; + deserialize_hex(hex_str).map_err(serde::de::Error::custom) + }) + .collect() + } + _ => Err(serde::de::Error::custom( + "expected a hex string or array of hex strings for headers", + )), } - Ok(items) } pub fn feerate_opt_from_btc_per_kb<'de, D>( diff --git a/src/pending_request.rs b/src/pending_request.rs index 4686b9c..797fca4 100644 --- a/src/pending_request.rs +++ b/src/pending_request.rs @@ -97,11 +97,15 @@ gen_pending_request_types! { ScriptHashSubscribe, ScriptHashUnsubscribe, BroadcastTx, + BroadcastPackage, GetTx, GetTxMerkle, GetTxidFromPos, GetFeeHistogram, + GetMempoolInfo, + ServerVersion, Banner, + Features, Ping, Custom } diff --git a/src/request.rs b/src/request.rs index 9973529..b1629ec 100644 --- a/src/request.rs +++ b/src/request.rs @@ -235,17 +235,50 @@ impl Request for HeadersWithCheckpoint { /// fee rate (in BTC per kilobyte) required to be included within the specified number of blocks. /// /// See: +/// The fee estimation mode passed to the server's `estimatesmartfee` RPC. +/// +/// Added in Electrum protocol v1.6. +/// +/// See: +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EstimateFeeMode { + /// Conservative fee estimation (less likely to underestimate). + Conservative, + /// Economical fee estimation (may underestimate for faster inclusion). + Economical, +} + +impl EstimateFeeMode { + fn as_str(&self) -> &'static str { + match self { + Self::Conservative => "CONSERVATIVE", + Self::Economical => "ECONOMICAL", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EstimateFee { /// The number of blocks to target for confirmation. pub number: usize, + + /// An optional estimation mode passed to the server's `estimatesmartfee` RPC. + /// + /// If `None`, the server uses its default mode. + /// + /// Added in Electrum protocol v1.6. + pub mode: Option, } impl Request for EstimateFee { type Response = response::EstimateFeeResp; fn to_method_and_params(&self) -> MethodAndParams { - ("blockchain.estimatefee".into(), vec![self.number.into()]) + let mut params: Vec = vec![self.number.into()]; + if let Some(mode) = &self.mode { + params.push(mode.as_str().into()); + } + ("blockchain.estimatefee".into(), params) } } @@ -271,6 +304,9 @@ impl Request for HeadersSubscribe { /// This corresponds to the `"server.relayfee"` Electrum RPC method. It returns the minimum /// fee rate (in BTC per kilobyte) that the server will accept for relaying transactions. /// +/// Removed in Electrum protocol v1.6 — use [`GetMempoolInfo`] (`mempool.get_info`) when +/// targeting v1.6+ servers. +/// /// See: #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RelayFee; @@ -589,6 +625,37 @@ impl Request for GetTxidFromPos { } } +/// A request to broadcast a package of transactions to the network. +/// +/// This corresponds to the `"blockchain.transaction.broadcast_package"` Electrum RPC method, +/// which submits a package of related transactions (e.g., for CPFP or package relay). +/// +/// Added in Electrum protocol v1.6. +/// +/// See: +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BroadcastPackage(pub Vec); + +impl Request for BroadcastPackage { + type Response = response::BroadcastPackageResp; + + fn to_method_and_params(&self) -> MethodAndParams { + let txs: Vec = self + .0 + .iter() + .map(|tx| { + let mut tx_bytes = Vec::::new(); + tx.consensus_encode(&mut tx_bytes).expect("must encode"); + tx_bytes.to_lower_hex_string().into() + }) + .collect(); + ( + "blockchain.transaction.broadcast_package".into(), + vec![txs.into()], + ) + } +} + /// A request for the current mempool fee histogram. /// /// This corresponds to the `"mempool.get_fee_histogram"` Electrum RPC method. It returns a compact @@ -607,6 +674,61 @@ impl Request for GetFeeHistogram { } } +/// A request to negotiate the protocol version with the Electrum server. +/// +/// This corresponds to the `"server.version"` Electrum RPC method. It identifies the client and +/// negotiates a compatible protocol version with the server. According to the Electrum protocol +/// specification, this should be the first message sent after connecting. +/// +/// The server will select the highest protocol version that both client and server support. +/// +/// See: +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ServerVersion { + /// A string identifying the client software (e.g., `"electrum_streaming_client/0.5"`). + pub client_name: CowStr, + + /// The protocol version or version range the client supports. + /// + /// Can be a single version string (e.g., `"1.6"`) or an array-style string for a range. + pub protocol_version: CowStr, +} + +impl Request for ServerVersion { + type Response = response::ServerVersionResp; + + fn to_method_and_params(&self) -> MethodAndParams { + ( + "server.version".into(), + vec![ + self.client_name.as_ref().into(), + self.protocol_version.as_ref().into(), + ], + ) + } +} + +/// A request for general mempool information from the Electrum server. +/// +/// This corresponds to the `"mempool.get_info"` Electrum RPC method. It returns fee-related +/// parameters including `mempoolminfee`, `minrelaytxfee`, and `incrementalrelayfee`. +/// +/// This replaces the `blockchain.relayfee` method, which was removed in v1.6. +/// +/// Added in Electrum protocol v1.6. +/// +/// See: +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GetMempoolInfo; + +impl Request for GetMempoolInfo { + type Response = response::MempoolInfoResp; + + fn to_method_and_params(&self) -> MethodAndParams { + ("mempool.get_info".into(), vec![]) + } +} + /// A request for the Electrum server's banner message. /// /// This corresponds to the `"server.banner"` Electrum RPC method, which returns a server-defined diff --git a/src/response.rs b/src/response.rs index 7510ec4..3731ed7 100644 --- a/src/response.rs +++ b/src/response.rs @@ -14,6 +14,20 @@ use bitcoin::{ use crate::DoubleSHA; +/// Response to the `"server.version"` method. +/// +/// Returns the server's software version and the negotiated protocol version. +/// +/// See: +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ServerVersionResp { + /// The server's software version string. + pub server_software: String, + + /// The negotiated protocol version string. + pub protocol_version: String, +} + /// Response to the `"blockchain.block.header"` method (without checkpoint). #[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] @@ -38,6 +52,9 @@ pub struct HeaderWithProofResp { } /// Response to the `"blockchain.block.headers"` method (without checkpoint). +/// +/// Supports both the pre-1.6 format (concatenated hex in `"hex"` field) and the v1.6 format +/// (array of hex strings in `"headers"` field). #[derive(Debug, Clone, serde::Deserialize)] pub struct HeadersResp { /// The number of headers returned. @@ -45,16 +62,20 @@ pub struct HeadersResp { /// The deserialized headers returned by the server. #[serde( - rename = "hex", - deserialize_with = "crate::custom_serde::from_cancat_consensus_hex" + alias = "hex", + alias = "headers", + deserialize_with = "crate::custom_serde::headers_from_hex_or_list" )] pub headers: Vec, - /// The server’s maximum allowed headers per request. + /// The server's maximum allowed headers per request. pub max: usize, } /// Response to the `"blockchain.block.headers"` method with a `cp_height` parameter. +/// +/// Supports both the pre-1.6 format (concatenated hex in `"hex"` field) and the v1.6 format +/// (array of hex strings in `"headers"` field). #[derive(Debug, Clone, serde::Deserialize)] pub struct HeadersWithCheckpointResp { /// The number of headers returned. @@ -62,12 +83,13 @@ pub struct HeadersWithCheckpointResp { /// The deserialized headers returned by the server. #[serde( - rename = "hex", - deserialize_with = "crate::custom_serde::from_cancat_consensus_hex" + alias = "hex", + alias = "headers", + deserialize_with = "crate::custom_serde::headers_from_hex_or_list" )] pub headers: Vec, - /// The server’s maximum allowed headers per request. + /// The server's maximum allowed headers per request. pub max: usize, /// The Merkle root of all headers up to the checkpoint height. @@ -281,6 +303,35 @@ pub struct FeePair { pub weight: bitcoin::Weight, } +/// Response to the `"blockchain.transaction.broadcast_package"` method (non-verbose mode). +/// +/// See: +#[derive(Debug, Clone, serde::Deserialize)] +pub struct BroadcastPackageResp { + /// Whether the package was accepted by the server. + pub success: bool, +} + +/// Response to the `"mempool.get_info"` method. +/// +/// Provides fee-related information about the server's mempool. All fee rates are in BTC/kvB. +/// +/// See: +#[derive(Debug, Clone, serde::Deserialize)] +pub struct MempoolInfoResp { + /// The minimum fee rate (BTC/kvB) for a transaction to be accepted into the mempool. + #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] + pub mempoolminfee: Option, + + /// The minimum relay fee rate (BTC/kvB). + #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] + pub minrelaytxfee: Option, + + /// The incremental relay fee rate (BTC/kvB). + #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] + pub incrementalrelayfee: Option, +} + /// Response to the `"server.features"` method. #[derive(Debug, Clone, serde::Deserialize)] pub struct ServerFeatures { From de675c0e51759a03077310f2e71c5f94a8bba789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 6 May 2026 16:42:03 +0000 Subject: [PATCH 2/2] test: Add unit tests for `headers_from_hex_or_list` Cover both the pre-1.6 concatenated-hex path and the v1.6 array-of-hex path, asserting they produce equal `Vec
`, plus a sanity check that non-string/non-array inputs are rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/custom_serde.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/custom_serde.rs b/src/custom_serde.rs index 1102f97..50ec32a 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -158,3 +158,34 @@ where } Ok(Version) } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "headers_from_hex_or_list")] + headers: Vec, + } + + #[test] + fn headers_from_hex_or_list_accepts_both_formats() { + // Any 80 bytes parse as a Header structurally; the test just checks both paths agree. + let h0 = "00".repeat(80); + let h1 = "ff".repeat(80); + + let concatenated: Wrapper = + serde_json::from_value(serde_json::json!({ "headers": format!("{h0}{h1}") })).unwrap(); + let array: Wrapper = + serde_json::from_value(serde_json::json!({ "headers": [h0, h1] })).unwrap(); + + assert_eq!(concatenated.headers.len(), 2); + assert_eq!(concatenated.headers, array.headers); + } + + #[test] + fn headers_from_hex_or_list_rejects_other_types() { + assert!(serde_json::from_value::(serde_json::json!({ "headers": 42 })).is_err()); + } +}