diff --git a/src/api.rs b/src/api.rs index 26cd769..a6d2fae 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 { @@ -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) } @@ -172,6 +179,18 @@ where (**self).server_features() } + fn mempool_get_info(&self) -> Result { + (**self).mempool_get_info() + } + + 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() } @@ -241,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, @@ -268,9 +302,15 @@ 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**. + /// + /// **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*. @@ -370,6 +410,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; @@ -398,6 +450,27 @@ 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. + /// 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>; @@ -449,7 +522,11 @@ mod test { unreachable!() } - fn estimate_fee(&self, _: usize) -> Result { + fn estimate_fee( + &self, + _: usize, + _: Option, + ) -> Result { unreachable!() } @@ -572,6 +649,13 @@ mod test { unreachable!() } + fn transaction_broadcast_package_raw>( + &self, + _: &[T], + ) -> Result { + unreachable!() + } + fn transaction_get_merkle( &self, _: &bitcoin::Txid, @@ -607,6 +691,18 @@ mod test { unreachable!() } + fn mempool_get_info(&self) -> Result { + unreachable!() + } + + fn server_version( + &self, + _: &str, + _: &str, + ) -> Result { + unreachable!() + } + fn ping(&self) -> Result<(), super::Error> { 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 7e4e626..c8bb78f 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] @@ -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) @@ -362,6 +370,20 @@ 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, + 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..5acd834 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -45,6 +45,26 @@ 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"; + +/// 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, ) @@ -142,6 +162,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 +186,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 +198,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 +215,9 @@ impl RawClient { None => TcpStream::connect(socket_addrs)?, }; - Ok(stream.into()) + let client: Self = stream.into(); + client.negotiate_protocol_version()?; + Ok(client) } } @@ -285,7 +315,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 +498,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 +530,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 +587,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() { @@ -877,23 +939,49 @@ 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) -> 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 +1186,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 { @@ -1113,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( @@ -1179,6 +1298,39 @@ 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, + 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)?; + let response: ServerVersionRes = 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> { let req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), @@ -1225,6 +1377,39 @@ 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 = "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 = "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() { @@ -1256,7 +1441,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 ce3ef9f..663c249 100644 --- a/src/types.rs +++ b/src/types.rs @@ -33,6 +33,31 @@ 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). +/// +/// 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)] @@ -202,17 +227,77 @@ pub struct ServerFeaturesRes { pub pruning: Option, } -/// Response to a [`server_features`](../client/struct.Client.html#method.server_features) request. +/// 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 GetHeadersRes { +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]`. +#[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 [`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(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, } @@ -251,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 {