Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
Expand Down
96 changes: 82 additions & 14 deletions src/custom_serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<T>, 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<Vec<T>, D::Error>
where
T: bitcoin::consensus::encode::Decodable,
D: Deserializer<'de>,
{
let hex_str = String::deserialize(deserializer)?;
let data = Vec::<u8>::from_hex(&hex_str).map_err(serde::de::Error::custom)?;

let mut items = Vec::<T>::new();
let mut read_start = 0_usize;
while read_start < data.len() {
let (item, read_count) =
deserialize_partial::<T>(&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::<u8>::from_hex(&hex_str).map_err(serde::de::Error::custom)?;
let mut items = Vec::<T>::new();
let mut read_start = 0_usize;
while read_start < data.len() {
let (item, read_count) = deserialize_partial::<T>(&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<E: Error>(btc_per_kvb: f32) -> Result<bitcoin::FeeRate, E> {
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<bitcoin::FeeRate, D::Error>
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<Option<bitcoin::FeeRate>, D::Error>
Expand All @@ -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<bitcoin::FeeRate, D::Error>
Expand Down Expand Up @@ -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<bitcoin::block::Header>,
}

#[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::<Wrapper>(serde_json::json!({ "headers": 42 })).is_err());
}
}
4 changes: 4 additions & 0 deletions src/pending_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ gen_pending_request_types! {
ScriptHashSubscribe,
ScriptHashUnsubscribe,
BroadcastTx,
BroadcastPackage,
GetTx,
GetTxMerkle,
GetTxidFromPos,
GetFeeHistogram,
GetMempoolInfo,
ServerVersion,
Banner,
Features,
Ping,
Custom
}
Expand Down
164 changes: 151 additions & 13 deletions src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()],
)
}
}

Expand Down Expand Up @@ -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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#blockchain-estimatefee>
#[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",
}
}
}

Expand All @@ -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<EstimateFeeMode>,
}

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<serde_json::Value> = vec![self.number.into()];
if let Some(mode) = &self.mode {
params.push(mode.as_str().into());
}
("blockchain.estimatefee".into(), params)
}
}

Expand All @@ -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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#server-relayfee>
/// Removed in Electrum protocol v1.6 — use [`GetMempoolInfo`] (`mempool.get_info`) when
/// targeting v1.6+ servers.
///
/// See: <https://electrum-protocol.readthedocs.io/en/latest/protocol-removed.html#blockchain-relayfee>
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RelayFee;

Expand Down Expand Up @@ -503,11 +543,9 @@ impl Request for BroadcastTx {
type Response = bitcoin::Txid;

fn to_method_and_params(&self) -> MethodAndParams {
let mut tx_bytes = Vec::<u8>::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()],
)
}
}
Expand Down Expand Up @@ -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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-broadcast-package>
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BroadcastPackage(pub Vec<bitcoin::Transaction>);

impl Request for BroadcastPackage {
type Response = response::BroadcastPackageResp;

fn to_method_and_params(&self) -> MethodAndParams {
let txs: Vec<serde_json::Value> =
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
Expand All @@ -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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#server-version>
#[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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#server-version>
#[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: <https://electrum-protocol.readthedocs.io/en/latest/protocol-methods.html#mempool-get-info>
#[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
Expand Down
Loading