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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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());
}
}
30 changes: 30 additions & 0 deletions src/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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())),
}
}
Expand Down Expand Up @@ -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 {
Comment thread
sdmg15 marked this conversation as resolved.
/// 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<response::TxTweak>,
Comment thread
sdmg15 marked this conversation as resolved.
}
40 changes: 29 additions & 11 deletions src/pending_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: <crate::request::$name as Request>::Response,
}),*,
$(
$(#[$attr])*
$name {
req: crate::request::$name,
resp: <crate::request::$name as Request>::Response,
},
)*
}

/// A request that received an error response from the Electrum server.
Expand All @@ -53,23 +56,32 @@ 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)
}
)*
}
}
}

impl std::error::Error for FailedRequest {}

$(
$(#[$attr])*
impl RequestExt for crate::request::$name {
fn into_completed(self, resp: <Self as Request>::Response) -> CompletedRequest {
CompletedRequest::$name { req: self, resp }
Expand Down Expand Up @@ -97,13 +109,19 @@ gen_pending_request_types! {
ScriptHashSubscribe,
ScriptHashUnsubscribe,
BroadcastTx,
BroadcastPackage,
GetTx,
GetTxMerkle,
GetTxidFromPos,
GetFeeHistogram,
GetMempoolInfo,
ServerVersion,
Banner,
Features,
Ping,
Custom
Comment thread
noahjoeris marked this conversation as resolved.
Custom,
#[cfg(feature = "frigate")] SpSubscribe,
#[cfg(feature = "frigate")] SpUnsubscribe
}

type Handler =
Expand Down
Loading