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/custom_serde.rs b/src/custom_serde.rs index 722fffb..90ac406 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -19,25 +19,63 @@ 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", + )), + } +} + +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")); } - Ok(items) + 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> @@ -48,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 @@ -137,3 +174,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()); + } +} 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..682b749 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, @@ -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,38 @@ 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(), - ] - }) + ], + ) + } +} + +/// 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 estimate: potentially higher feerate, more likely to meet the target, less + /// responsive to short-term fee drops. + Conservative, + /// Economical estimate: potentially lower feerate, more responsive to short-term fee drops, may + /// take longer to confirm. + Economical, +} + +impl EstimateFeeMode { + fn as_str(&self) -> &'static str { + match self { + Self::Conservative => "CONSERVATIVE", + Self::Economical => "ECONOMICAL", + } } } @@ -239,13 +265,24 @@ impl Request for HeadersWithCheckpoint { 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) } } @@ -268,10 +305,13 @@ 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. /// -/// See: +/// 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; @@ -503,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()], ) } } @@ -589,6 +627,30 @@ 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| serialize_hex(tx).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 +669,82 @@ 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 +/// 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 range the client supports. + pub protocol_version: SupportedVersion, +} + +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).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`. +/// +/// Added in Electrum protocol v1.6 and replaces the `blockchain.relayfee` method. +/// +/// 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..914db6b 100644 --- a/src/response.rs +++ b/src/response.rs @@ -14,6 +14,30 @@ 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)] +#[serde(from = "(String, String)")] +pub struct ServerVersionResp { + /// Server software version (e.g. `"ElectrumX 1.18.0"`). + pub server_software: 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)] @@ -38,6 +62,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 +72,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 +93,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. @@ -100,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 { @@ -281,6 +313,50 @@ 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, + + /// 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. +/// +/// Provides fee-related information about the server's mempool. +/// +/// See: +#[derive(Debug, Clone, serde::Deserialize)] +pub struct MempoolInfoResp { + /// 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. + #[serde(deserialize_with = "crate::custom_serde::feerate_from_btc_per_kb")] + pub minrelaytxfee: bitcoin::FeeRate, + + /// 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. #[derive(Debug, Clone, serde::Deserialize)] pub struct ServerFeatures {