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/9] 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/9] 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()); + } +} From 559e8df41dde8d227b0ebee285c3a7fcb0e1cf19 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Fri, 24 Jul 2026 14:58:49 +0300 Subject: [PATCH 3/9] fix(docs): correct EstimateFee and EstimateFeeMode docs --- src/request.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/request.rs b/src/request.rs index b1629ec..eaad796 100644 --- a/src/request.rs +++ b/src/request.rs @@ -228,13 +228,6 @@ impl Request for HeadersWithCheckpoint { } } -/// A request for an estimated fee rate needed to confirm a transaction within a target number of -/// blocks. -/// -/// This corresponds to the `"blockchain.estimatefee"` Electrum RPC method. It returns the estimated -/// 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. @@ -242,9 +235,11 @@ impl Request for HeadersWithCheckpoint { /// See: #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EstimateFeeMode { - /// Conservative fee estimation (less likely to underestimate). + /// Conservative estimate: potentially higher feerate, more likely to meet the target, less + /// responsive to short-term fee drops. Conservative, - /// Economical fee estimation (may underestimate for faster inclusion). + /// Economical estimate: potentially lower feerate, more responsive to short-term fee drops, may + /// take longer to confirm. Economical, } @@ -257,6 +252,13 @@ impl EstimateFeeMode { } } +/// A request for an estimated fee rate needed to confirm a transaction within a target number of +/// blocks. +/// +/// This corresponds to the `"blockchain.estimatefee"` Electrum RPC method. It returns the estimated +/// fee rate (in BTC per kilobyte) required to be included within the specified number of blocks. +/// +/// See: #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EstimateFee { /// The number of blocks to target for confirmation. From a17547baea866ada485f8a6fb38f0f83ac407566 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Fri, 24 Jul 2026 16:18:12 +0300 Subject: [PATCH 4/9] fix: version request/response --- src/request.rs | 47 ++++++++++++++++++++++++++++++++++++----------- src/response.rs | 14 ++++++++++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/request.rs b/src/request.rs index eaad796..3f6b264 100644 --- a/src/request.rs +++ b/src/request.rs @@ -185,9 +185,10 @@ impl Request for Headers { type Response = response::HeadersResp; fn to_method_and_params(&self) -> MethodAndParams { - ("blockchain.block.headers".into(), { - vec![self.start_height.into(), self.count.into()] - }) + ( + "blockchain.block.headers".into(), + vec![self.start_height.into(), self.count.into()], + ) } } @@ -218,13 +219,14 @@ impl Request for HeadersWithCheckpoint { type Response = response::HeadersWithCheckpointResp; fn to_method_and_params(&self) -> MethodAndParams { - ("blockchain.block.headers".into(), { + ( + "blockchain.block.headers".into(), vec![ self.start_height.into(), self.count.into(), self.cp_height.into(), - ] - }) + ], + ) } } @@ -676,6 +678,31 @@ impl Request for GetFeeHistogram { } } +/// The `protocol_version` param for [`ServerVersion`]. +/// +/// Corresponds to the second argument of `server.version`: either a single version string, or a +/// `[protocol_min, protocol_max]` range. +/// +/// See: +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SupportedVersion { + /// A single version string (e.g. `"1.6"`). Equivalent to a range where min == max. + Exact(CowStr), + /// A version range `[protocol_min, protocol_max]` (e.g. `["1.4", "1.6"]`). + Range([CowStr; 2]), +} + +impl From<&SupportedVersion> for serde_json::Value { + fn from(value: &SupportedVersion) -> Self { + match value { + SupportedVersion::Exact(version) => version.as_ref().into(), + SupportedVersion::Range([min, max]) => { + serde_json::Value::Array(vec![min.as_ref().into(), max.as_ref().into()]) + } + } + } +} + /// 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 @@ -690,10 +717,8 @@ 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, + /// The protocol version range the client supports. + pub protocol_version: SupportedVersion, } impl Request for ServerVersion { @@ -704,7 +729,7 @@ impl Request for ServerVersion { "server.version".into(), vec![ self.client_name.as_ref().into(), - self.protocol_version.as_ref().into(), + (&self.protocol_version).into(), ], ) } diff --git a/src/response.rs b/src/response.rs index 3731ed7..6324d85 100644 --- a/src/response.rs +++ b/src/response.rs @@ -20,14 +20,24 @@ use crate::DoubleSHA; /// /// See: #[derive(Debug, Clone, serde::Deserialize)] +#[serde(from = "(String, String)")] pub struct ServerVersionResp { - /// The server's software version string. + /// Server software version (e.g. `"ElectrumX 1.18.0"`). pub server_software: String, - /// The negotiated protocol version string. + /// Negotiated protocol version (e.g. `"1.4"`). pub protocol_version: String, } +impl From<(String, String)> for ServerVersionResp { + fn from((server_software, protocol_version): (String, String)) -> Self { + Self { + server_software, + protocol_version, + } + } +} + /// Response to the `"blockchain.block.header"` method (without checkpoint). #[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] From 6ef81e9012327dcb33d0002298025bd73b7cc107 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Fri, 24 Jul 2026 17:14:51 +0300 Subject: [PATCH 5/9] feat: Add per-tx errors to BroadcastPackageResp Capture rejection details when package broadcast fails, and simplify broadcast hex encoding with serialize_hex. --- src/request.rs | 17 ++++------------- src/response.rs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/request.rs b/src/request.rs index 3f6b264..3dd291d 100644 --- a/src/request.rs +++ b/src/request.rs @@ -16,7 +16,7 @@ //! [`to_method_and_params`]: Request::to_method_and_params //! [`Response`]: Request::Response -use bitcoin::{consensus::Encodable, hex::DisplayHex, Script, Txid}; +use bitcoin::{consensus::encode::serialize_hex, Script, Txid}; use crate::{ response, CowStr, ElectrumScriptHash, ElectrumScriptStatus, MethodAndParams, RawRequest, @@ -543,11 +543,9 @@ impl Request for BroadcastTx { type Response = bitcoin::Txid; fn to_method_and_params(&self) -> MethodAndParams { - let mut tx_bytes = Vec::::new(); - self.0.consensus_encode(&mut tx_bytes).expect("must encode"); ( "blockchain.transaction.broadcast".into(), - vec![tx_bytes.to_lower_hex_string().into()], + vec![serialize_hex(&self.0).into()], ) } } @@ -644,15 +642,8 @@ 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(); + let txs: Vec = + self.0.iter().map(|tx| serialize_hex(tx).into()).collect(); ( "blockchain.transaction.broadcast_package".into(), vec![txs.into()], diff --git a/src/response.rs b/src/response.rs index 6324d85..73821d1 100644 --- a/src/response.rs +++ b/src/response.rs @@ -320,6 +320,21 @@ pub struct FeePair { pub struct BroadcastPackageResp { /// Whether the package was accepted by the server. pub success: bool, + + /// Per-transaction errors for txs that were not accepted, if any. + /// + /// Present when `success` is `false`. + pub errors: Option>, +} + +/// A per-transaction rejection inside [`BroadcastPackageResp::errors`]. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct BroadcastPackageError { + /// The rejected transaction's txid. + pub txid: bitcoin::Txid, + + /// The rejection reason (e.g. `"bad-txns-inputs-missingorspent"`). + pub error: String, } /// Response to the `"mempool.get_info"` method. From 3c9c991c5c38fab6180a98d689a421c997a70d5a Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Fri, 24 Jul 2026 17:31:28 +0300 Subject: [PATCH 6/9] fix: Require FeeRate fields on MempoolInfoResp mempool.get_info returns required BTC/kvB floats, so deserialize to FeeRate directly and share conversion with the estimatefee opt helper. --- src/custom_serde.rs | 20 ++++++++++++++++++-- src/request.rs | 6 ++---- src/response.rs | 20 ++++++++++---------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/custom_serde.rs b/src/custom_serde.rs index 50ec32a..90ac406 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -59,6 +59,23 @@ where } } +fn feerate_from_btc_per_kb_f32(btc_per_kvb: f32) -> Result { + if btc_per_kvb.is_sign_negative() { + return Err(E::custom("expected non-negative fee rate in BTC/kvB")); + } + let sat_per_kwu = btc_per_kvb * (100_000_000.0 / 4.0); + Ok(bitcoin::FeeRate::from_sat_per_kwu(sat_per_kwu as _)) +} + +/// BTC/kvB → [`bitcoin::FeeRate`]; errors if negative. +pub fn feerate_from_btc_per_kb<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + feerate_from_btc_per_kb_f32(f32::deserialize(deserializer)?) +} + +/// BTC/kvB → [`bitcoin::FeeRate`]; negative → `None`. pub fn feerate_opt_from_btc_per_kb<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -69,8 +86,7 @@ where if btc_per_kvb.is_sign_negative() { return Ok(None); } - let sat_per_kwu = btc_per_kvb * (100_000_000.0 / 4.0); - Ok(Some(bitcoin::FeeRate::from_sat_per_kwu(sat_per_kwu as _))) + feerate_from_btc_per_kb_f32(btc_per_kvb).map(Some) } pub fn feerate_from_sat_per_byte<'de, D>(deserializer: D) -> Result diff --git a/src/request.rs b/src/request.rs index 3dd291d..9a02e43 100644 --- a/src/request.rs +++ b/src/request.rs @@ -311,7 +311,7 @@ impl Request for HeadersSubscribe { /// Removed in Electrum protocol v1.6 — use [`GetMempoolInfo`] (`mempool.get_info`) when /// targeting v1.6+ servers. /// -/// See: +/// See: #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RelayFee; @@ -731,9 +731,7 @@ impl Request for ServerVersion { /// 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. +/// Added in Electrum protocol v1.6 and replaces the `blockchain.relayfee` method. /// /// See: #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/response.rs b/src/response.rs index 73821d1..206d5c1 100644 --- a/src/response.rs +++ b/src/response.rs @@ -339,22 +339,22 @@ pub struct BroadcastPackageError { /// Response to the `"mempool.get_info"` method. /// -/// Provides fee-related information about the server's mempool. All fee rates are in BTC/kvB. +/// Provides fee-related information about the server's mempool. /// /// 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 fee rate for a transaction to be accepted into the mempool. + #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + pub mempoolminfee: bitcoin::FeeRate, - /// The minimum relay fee rate (BTC/kvB). - #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] - pub minrelaytxfee: Option, + /// The minimum relay fee rate. + #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + pub minrelaytxfee: bitcoin::FeeRate, - /// The incremental relay fee rate (BTC/kvB). - #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] - pub incrementalrelayfee: Option, + /// The incremental relay fee rate. + #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + pub incrementalrelayfee: bitcoin::FeeRate, } /// Response to the `"server.features"` method. From c1619bcadc9581f496e3c14610cc418f16d51969 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Fri, 24 Jul 2026 17:35:50 +0300 Subject: [PATCH 7/9] fix(docs): Correct relayfee method name and README example Use blockchain.relayfee in docs and switch the README sample to GetMempoolInfo for v1.6. --- README.md | 6 ++++-- src/request.rs | 2 +- src/response.rs | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77bac7c..620a76d 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,10 @@ async fn main() -> anyhow::Result<()> { tokio::spawn(worker); // spawn the client worker task - let relay_fee = client.send_request(electrum_streaming_client::request::RelayFee).await?; - println!("Relay fee: {relay_fee:?}"); + let mempool_info = client + .send_request(electrum_streaming_client::request::GetMempoolInfo) + .await?; + println!("Mempool info: {mempool_info:?}"); while let Some(event) = events.next().await { println!("Event: {event:?}"); diff --git a/src/request.rs b/src/request.rs index 9a02e43..682b749 100644 --- a/src/request.rs +++ b/src/request.rs @@ -305,7 +305,7 @@ impl Request for HeadersSubscribe { /// A request for the minimum fee rate accepted by the Electrum server's mempool. /// -/// This corresponds to the `"server.relayfee"` Electrum RPC method. It returns the minimum +/// This corresponds to the `"blockchain.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 diff --git a/src/response.rs b/src/response.rs index 206d5c1..914db6b 100644 --- a/src/response.rs +++ b/src/response.rs @@ -132,7 +132,7 @@ pub struct HeadersSubscribeResp { pub height: u32, } -/// Response to the `"server.relayfee"` method. +/// Response to the `"blockchain.relayfee"` method. #[derive(Debug, Clone, serde::Deserialize)] #[serde(transparent)] pub struct RelayFeeResp { From 223eba6a14c21713e3b8a2c1c2cd1bfc039a1d00 Mon Sep 17 00:00:00 2001 From: Sonkeng Maldini Date: Mon, 31 Aug 2026 13:47:59 +0100 Subject: [PATCH 8/9] feat: support attributes in gen_pending_request_types! --- src/pending_request.rs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/pending_request.rs b/src/pending_request.rs index 797fca4..7319dc2 100644 --- a/src/pending_request.rs +++ b/src/pending_request.rs @@ -17,7 +17,7 @@ pub trait RequestExt: Request + Sized { } macro_rules! gen_pending_request_types { - ($($name:ident),*) => { + ($($(#[$attr:meta])* $name:ident),* $(,)?) => { /// A successfully handled request and its decoded server response. /// /// This enum is returned when a request has been fully processed and the server replied @@ -33,10 +33,13 @@ macro_rules! gen_pending_request_types { /// [`Event::Response`]: crate::Event::Response #[derive(Debug, Clone)] pub enum CompletedRequest { - $($name { - req: crate::request::$name, - resp: ::Response, - }),*, + $( + $(#[$attr])* + $name { + req: crate::request::$name, + resp: ::Response, + }, + )* } /// A request that received an error response from the Electrum server. @@ -53,16 +56,24 @@ macro_rules! gen_pending_request_types { /// [`Event::ResponseError`]: crate::Event::ResponseError #[derive(Debug, Clone)] pub enum FailedRequest { - $($name { - req: crate::request::$name, - error: ResponseError, - }),*, + $( + $(#[$attr])* + $name { + req: crate::request::$name, + error: ResponseError, + }, + )* } impl core::fmt::Display for FailedRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - $(Self::$name { req, error } => write!(f, "Server responsed to {:?} with error: {}", req, error)),*, + $( + $(#[$attr])* + Self::$name { req, error } => { + write!(f, "Server responsed to {:?} with error: {}", req, error) + } + )* } } } @@ -70,6 +81,7 @@ macro_rules! gen_pending_request_types { impl std::error::Error for FailedRequest {} $( + $(#[$attr])* impl RequestExt for crate::request::$name { fn into_completed(self, resp: ::Response) -> CompletedRequest { CompletedRequest::$name { req: self, resp } From f8e9178bf2f67af98364363028d909a65d741834 Mon Sep 17 00:00:00 2001 From: Sonkeng Maldini Date: Mon, 31 Aug 2026 13:48:31 +0100 Subject: [PATCH 9/9] feat: add Frigate Silent Payments RPC support --- Cargo.toml | 1 + src/notification.rs | 30 ++++++++++++++ src/pending_request.rs | 4 +- src/request.rs | 88 ++++++++++++++++++++++++++++++++++++++++++ src/response.rs | 28 ++++++++++++++ 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5e3c99a..3a42602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tokio-util = { version = "0.7.15", features = ["compat"], optional = true } [features] default = ["tokio"] tokio = ["dep:tokio", "tokio-util"] +frigate = [] [dev-dependencies] async-std = "1.13.0" diff --git a/src/notification.rs b/src/notification.rs index 6975110..b1f07e1 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -5,6 +5,7 @@ //! //! - [`Notification::Header`] for `"blockchain.headers.subscribe"` //! - [`Notification::ScriptHash`] for `"blockchain.scripthash.subscribe"` +//! - `Notification::SpSubscribe` for `"blockchain.silentpayments.subscribe"` (requires the `frigate` feature) //! - [`Notification::Unknown`] for unrecognized or unsupported methods //! //! Each variant wraps a struct that contains the deserialized payload for that notification type. @@ -32,6 +33,11 @@ pub enum Notification { /// status. ScriptHash(ScriptHashNotification), + /// A notification from `"blockchain.silentpayments.subscribe"` indicating a new history + /// of transactions + #[cfg(feature = "frigate")] + SpSubscribe(SpNotification), + /// A catch-all for notifications with unrecognized methods. /// /// The original [`RawNotification`] is preserved for downstream inspection. @@ -52,6 +58,10 @@ impl Notification { "blockchain.scripthash.subscribe" => { ScriptHashNotification::deserialize(params).map(Notification::ScriptHash) } + #[cfg(feature = "frigate")] + "blockchain.silentpayments.subscribe" => { + SpNotification::deserialize(params).map(Notification::SpSubscribe) + } _ => Ok(Notification::Unknown(raw.clone())), } } @@ -102,3 +112,23 @@ impl ScriptHashNotification { self.param_1 } } + +/// An update for a Silent Payments subscription. +/// +/// Corresponds to `"blockchain.silentpayments.subscribe"` Frigate Electrum notification method. +#[cfg(feature = "frigate")] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct SpNotification { + /// Identifies the subscription to which this notification belongs. + pub subscription: response::SpSubscribeResp, + + /// Historical scan progress from `0.0` through `1.0`. + /// + /// A value of `1.0` indicates that the scan is up to date. + pub progress: f32, + + /// Transactions discovered by the scan. + /// + /// Confirmed transactions are ordered by block height. + pub history: Vec, +} diff --git a/src/pending_request.rs b/src/pending_request.rs index 7319dc2..0397c30 100644 --- a/src/pending_request.rs +++ b/src/pending_request.rs @@ -119,7 +119,9 @@ gen_pending_request_types! { Banner, Features, Ping, - Custom + Custom, + #[cfg(feature = "frigate")] SpSubscribe, + #[cfg(feature = "frigate")] SpUnsubscribe } type Handler = diff --git a/src/request.rs b/src/request.rs index 682b749..3ff1223 100644 --- a/src/request.rs +++ b/src/request.rs @@ -794,3 +794,91 @@ impl Request for Ping { ("server.ping".into(), vec![]) } } + +/// A request to subscribe to payment outputs belonging to the provided keys +/// +/// This corresponds to the `"blockchain.silentpayments.subscribe"` Frigate Electrum RPC method. +/// The server returns the subscribed silent payment address. +/// +/// Supported Frigate version: <= 1.4.1 +/// +/// See: +#[cfg(feature = "frigate")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpSubscribe { + /// Private scan key sent to the server to detect matching Silent Payments outputs. + pub scan_priv_key: bitcoin::secp256k1::SecretKey, + + /// Public spend key paired with the scan key for this subscription. + pub spend_pub_key: bitcoin::secp256k1::PublicKey, + + /// Optional block height or timestamp from which to start scanning. + /// + /// Values above 500,000,000 are treated as seconds since the Unix epoch. + pub start_height: Option, + + /// Optional positive silent payment labels to scan for. + /// + /// Label `0` is scanned regardless of this value. + pub labels: Option>, +} + +#[cfg(feature = "frigate")] +impl Request for SpSubscribe { + type Response = String; + + fn to_method_and_params(&self) -> MethodAndParams { + let mut params = vec![ + serde_json::json!(self.scan_priv_key), + serde_json::json!(self.spend_pub_key), + ]; + + match (self.start_height, &self.labels) { + (Some(start_height), Some(labels)) => { + params.extend([start_height.into(), labels.clone().into()]); + } + + (Some(start_height), None) => params.push(start_height.into()), + (None, Some(labels)) => { + params.extend([serde_json::Value::Null, labels.clone().into()]); + } + (None, None) => {} + } + + ("blockchain.silentpayments.subscribe".into(), params) + } +} + +/// A request to unsubscribe from payment outputs belonging to the provided keys +/// +/// This corresponds to the `"blockchain.silentpayments.unsubscribe"` Frigate Electrum RPC method. +/// It returns the silent payment address that has been unsubscribed. This should cancel any scans +/// that may be currently running for this address. +/// +/// Supported Frigate version <= 1.4.1 +/// +/// See: +#[cfg(feature = "frigate")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpUnsubscribe { + /// Private scan key identifying the subscription to cancel. + pub scan_priv_key: bitcoin::secp256k1::SecretKey, + + /// Public spend key paired with the scan key for the subscription to cancel. + pub spend_pub_key: bitcoin::secp256k1::PublicKey, +} + +#[cfg(feature = "frigate")] +impl Request for SpUnsubscribe { + type Response = String; + + fn to_method_and_params(&self) -> MethodAndParams { + ( + "blockchain.silentpayments.unsubscribe".into(), + vec![ + serde_json::json!(self.scan_priv_key), + serde_json::json!(self.spend_pub_key), + ], + ) + } +} diff --git a/src/response.rs b/src/response.rs index 914db6b..5f14da4 100644 --- a/src/response.rs +++ b/src/response.rs @@ -394,3 +394,31 @@ pub struct ServerHostValues { /// TCP Port. pub tcp_port: Option, } + +/// Response entry from the `"blockchain.silentpayments.subscribe"` method. +#[cfg(feature = "frigate")] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct SpSubscribeResp { + /// The silent payment address that has been subscribed. + pub address: String, + + /// An array of the labels that are subscribed to (must include 0). + pub labels: Vec, + + /// The block height from which the subscription scan was started. + pub start_height: u32, +} + +/// A transaction returned by `"blockchain.silentpayments.subscribe"` notification. +#[cfg(feature = "frigate")] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TxTweak { + /// The block height at which the transaction was confirmed, or `0` for a mempool transaction. + pub height: u32, + + /// The transaction hash in hexadecimal. + pub tx_hash: bitcoin::Txid, + + /// The tweak key (input_hash*A) for the transaction in compressed format. + pub tweak_key: bitcoin::secp256k1::PublicKey, +}