From 6971a282a7700ca03eded3a1728612e0b7116700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 21 Aug 2026 10:28:58 +0000 Subject: [PATCH] feat: Add `Serialize` to `response` types Every type in `response` derived `Deserialize` only, so a downstream crate that wanted to persist a cached response (e.g. a script's history of `response::Tx`) had to define a mirror type and hand-write the conversions just to get the data back out. Several fields decode through `custom_serde` helpers, so each one gains a symmetrically-named serializer that writes the Electrum wire representation back out rather than the Rust type's own serde: - `to_consensus_hex` / `to_cancat_consensus_hex` - `feerate_opt_to_btc_per_kb` (writes `-1.0` for `None`) - `feerate_to_sat_per_byte` - `weight_to_vb` - `amount_to_btc` / `amount_to_sats` / `amount_to_maybe_negative_sats` - `all_inputs_confirmed_bool_to_height` (writes `0` / `-1`, not a bool) `PartialEq`/`Eq` are derived on the types that lacked them so the round trip can be asserted in tests. Co-Authored-By: Claude Opus 5 --- src/custom_serde.rs | 101 ++++++++++++++- src/response.rs | 295 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 357 insertions(+), 39 deletions(-) diff --git a/src/custom_serde.rs b/src/custom_serde.rs index 722fffb..c05ba91 100644 --- a/src/custom_serde.rs +++ b/src/custom_serde.rs @@ -1,10 +1,10 @@ use bitcoin::{ - consensus::{deserialize_partial, encode::deserialize_hex}, - hex::FromHex, + consensus::{deserialize_partial, encode::deserialize_hex, Encodable}, + hex::{DisplayHex, FromHex}, }; use serde::{ de::{Error, Unexpected}, - Deserialize, Deserializer, + Deserialize, Deserializer, Serialize, Serializer, }; use serde_json::Value; @@ -19,6 +19,14 @@ where deserialize_hex(&hex_str).map_err(serde::de::Error::custom) } +pub fn to_consensus_hex(value: &T, serializer: S) -> Result +where + T: Encodable, + S: Serializer, +{ + bitcoin::consensus::encode::serialize_hex(value).serialize(serializer) +} + pub fn from_cancat_consensus_hex<'de, T, D>(deserializer: D) -> Result, D::Error> where T: bitcoin::consensus::encode::Decodable, @@ -38,6 +46,20 @@ where Ok(items) } +pub fn to_cancat_consensus_hex(values: &[T], serializer: S) -> Result +where + T: Encodable, + S: Serializer, +{ + let mut data = Vec::::new(); + for value in values { + value + .consensus_encode(&mut data) + .map_err(serde::ser::Error::custom)?; + } + data.to_lower_hex_string().serialize(serializer) +} + pub fn feerate_opt_from_btc_per_kb<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -52,6 +74,22 @@ where Ok(Some(bitcoin::FeeRate::from_sat_per_kwu(sat_per_kwu as _))) } +/// The Electrum API signals "no estimate available" with a negative number, so [`None`] is written +/// back out as `-1.0`. +pub fn feerate_opt_to_btc_per_kb( + fee_rate: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + let btc_per_kvb = match fee_rate { + Some(fee_rate) => fee_rate.to_sat_per_kwu() as f32 / (100_000_000.0 / 4.0), + None => -1.0, + }; + btc_per_kvb.serialize(serializer) +} + pub fn feerate_from_sat_per_byte<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -61,6 +99,17 @@ where Ok(bitcoin::FeeRate::from_sat_per_kwu(sat_per_kwu as _)) } +pub fn feerate_to_sat_per_byte( + fee_rate: &bitcoin::FeeRate, + serializer: S, +) -> Result +where + S: Serializer, +{ + let sat_per_vb = fee_rate.to_sat_per_kwu() as f32 / (1000.0 / 4.0); + sat_per_vb.serialize(serializer) +} + pub fn weight_from_vb<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -72,6 +121,13 @@ where Ok(weight) } +pub fn weight_to_vb(weight: &bitcoin::Weight, serializer: S) -> Result +where + S: Serializer, +{ + weight.to_vbytes_floor().serialize(serializer) +} + pub fn amount_from_btc<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -80,6 +136,13 @@ where bitcoin::Amount::from_btc(btc).map_err(serde::de::Error::custom) } +pub fn amount_to_btc(amount: &bitcoin::Amount, serializer: S) -> Result +where + S: Serializer, +{ + amount.to_btc().serialize(serializer) +} + pub fn amount_from_sats<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -88,6 +151,13 @@ where Ok(bitcoin::Amount::from_sat(sats)) } +pub fn amount_to_sats(amount: &bitcoin::Amount, serializer: S) -> Result +where + S: Serializer, +{ + amount.to_sat().serialize(serializer) +} + pub fn amount_from_maybe_negative_sats<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -96,6 +166,19 @@ where Ok(bitcoin::Amount::from_sat(sats)) } +/// Note that [`amount_from_maybe_negative_sats`] takes the absolute value of the wire number, so a +/// negative balance cannot be recovered here and is always written back as non-negative. +pub fn amount_to_maybe_negative_sats( + amount: &bitcoin::Amount, + serializer: S, +) -> Result +where + S: Serializer, +{ + let sats = i64::try_from(amount.to_sat()).map_err(serde::ser::Error::custom)?; + sats.serialize(serializer) +} + pub fn all_inputs_confirmed_bool_from_height<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -110,6 +193,18 @@ where } } +/// Writes back the Electrum `height` field: `0` when all inputs are confirmed, `-1` otherwise. +pub fn all_inputs_confirmed_bool_to_height( + all_inputs_confirmed: &bool, + serializer: S, +) -> Result +where + S: Serializer, +{ + let height: i64 = if *all_inputs_confirmed { 0 } else { -1 }; + height.serialize(serializer) +} + pub fn result<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/src/response.rs b/src/response.rs index 7510ec4..c6d3d30 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,8 +1,12 @@ //! Types representing structured responses returned by the Electrum server. //! -//! This module defines deserializable Rust types that correspond to the return values of various -//! Electrum JSON-RPC methods. These types are used to decode responses for specific request types -//! defined in the [`crate::request`] module. +//! This module defines Rust types that correspond to the return values of various Electrum +//! JSON-RPC methods. These types are used to decode responses for specific request types defined in +//! the [`crate::request`] module. +//! +//! Every type here also implements [`serde::Serialize`] so that responses can be cached or +//! persisted. Serialization is symmetric with deserialization: the emitted JSON is the Electrum +//! wire representation, so it can be fed straight back into [`serde::Deserialize`]. use std::collections::HashMap; @@ -15,22 +19,28 @@ use bitcoin::{ use crate::DoubleSHA; /// Response to the `"blockchain.block.header"` method (without checkpoint). -#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] pub struct HeaderResp { /// The block header at the requested height. - #[serde(deserialize_with = "crate::custom_serde::from_consensus_hex")] + #[serde( + deserialize_with = "crate::custom_serde::from_consensus_hex", + serialize_with = "crate::custom_serde::to_consensus_hex" + )] pub header: bitcoin::block::Header, } /// Response to the `"blockchain.block.header"` method with a `cp_height` parameter. -#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeaderWithProofResp { /// A Merkle branch connecting the header to the provided checkpoint root. pub branch: Vec, /// The block header at the requested height. - #[serde(deserialize_with = "crate::custom_serde::from_consensus_hex")] + #[serde( + deserialize_with = "crate::custom_serde::from_consensus_hex", + serialize_with = "crate::custom_serde::to_consensus_hex" + )] pub header: bitcoin::block::Header, /// The Merkle root for the header chain up to the checkpoint height. @@ -38,7 +48,7 @@ pub struct HeaderWithProofResp { } /// Response to the `"blockchain.block.headers"` method (without checkpoint). -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeadersResp { /// The number of headers returned. pub count: usize, @@ -46,7 +56,8 @@ pub struct HeadersResp { /// The deserialized headers returned by the server. #[serde( rename = "hex", - deserialize_with = "crate::custom_serde::from_cancat_consensus_hex" + deserialize_with = "crate::custom_serde::from_cancat_consensus_hex", + serialize_with = "crate::custom_serde::to_cancat_consensus_hex" )] pub headers: Vec, @@ -55,7 +66,7 @@ pub struct HeadersResp { } /// Response to the `"blockchain.block.headers"` method with a `cp_height` parameter. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeadersWithCheckpointResp { /// The number of headers returned. pub count: usize, @@ -63,7 +74,8 @@ pub struct HeadersWithCheckpointResp { /// The deserialized headers returned by the server. #[serde( rename = "hex", - deserialize_with = "crate::custom_serde::from_cancat_consensus_hex" + deserialize_with = "crate::custom_serde::from_cancat_consensus_hex", + serialize_with = "crate::custom_serde::to_cancat_consensus_hex" )] pub headers: Vec, @@ -78,21 +90,25 @@ pub struct HeadersWithCheckpointResp { } /// Response to the `"blockchain.estimatefee"` method. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] pub struct EstimateFeeResp { /// The estimated fee rate, or `None` if the server could not estimate. - #[serde(deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb")] + #[serde( + deserialize_with = "crate::custom_serde::feerate_opt_from_btc_per_kb", + serialize_with = "crate::custom_serde::feerate_opt_to_btc_per_kb" + )] pub fee_rate: Option, } /// Response to the `"blockchain.headers.subscribe"` method. -#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct HeadersSubscribeResp { /// The latest block header known to the server. #[serde( rename = "hex", - deserialize_with = "crate::custom_serde::from_consensus_hex" + deserialize_with = "crate::custom_serde::from_consensus_hex", + serialize_with = "crate::custom_serde::to_consensus_hex" )] pub header: bitcoin::block::Header, @@ -101,27 +117,36 @@ pub struct HeadersSubscribeResp { } /// Response to the `"server.relayfee"` method. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] pub struct RelayFeeResp { /// The minimum fee amount that the server will accept for relaying transactions. - #[serde(deserialize_with = "crate::custom_serde::amount_from_btc")] + #[serde( + deserialize_with = "crate::custom_serde::amount_from_btc", + serialize_with = "crate::custom_serde::amount_to_btc" + )] pub fee: Amount, } /// Response to the `"blockchain.scripthash.get_balance"` method. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct GetBalanceResp { /// The confirmed balance in satoshis. - #[serde(deserialize_with = "crate::custom_serde::amount_from_sats")] + #[serde( + deserialize_with = "crate::custom_serde::amount_from_sats", + serialize_with = "crate::custom_serde::amount_to_sats" + )] pub confirmed: Amount, /// The unconfirmed balance in satoshis (may be negative). - #[serde(deserialize_with = "crate::custom_serde::amount_from_maybe_negative_sats")] + #[serde( + deserialize_with = "crate::custom_serde::amount_from_maybe_negative_sats", + serialize_with = "crate::custom_serde::amount_to_maybe_negative_sats" + )] pub unconfirmed: Amount, } -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum Tx { Mempool(MempoolTx), @@ -159,7 +184,7 @@ impl Tx { } /// A confirmed transaction entry returned by `"blockchain.scripthash.get_history"`. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ConfirmedTx { /// The transaction ID. #[serde(rename = "tx_hash")] @@ -170,26 +195,30 @@ pub struct ConfirmedTx { } /// An unconfirmed transaction returned by `"blockchain.scripthash.get_mempool"`. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct MempoolTx { /// The transaction ID. #[serde(rename = "tx_hash")] pub txid: bitcoin::Txid, /// The fee paid by the transaction in satoshis. - #[serde(deserialize_with = "crate::custom_serde::amount_from_sats")] + #[serde( + deserialize_with = "crate::custom_serde::amount_from_sats", + serialize_with = "crate::custom_serde::amount_to_sats" + )] pub fee: bitcoin::Amount, /// Whether all inputs are confirmed. #[serde( rename = "height", - deserialize_with = "crate::custom_serde::all_inputs_confirmed_bool_from_height" + deserialize_with = "crate::custom_serde::all_inputs_confirmed_bool_from_height", + serialize_with = "crate::custom_serde::all_inputs_confirmed_bool_to_height" )] pub confirmed_inputs: bool, } /// Response entry from the `"blockchain.scripthash.listunspent"` method. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Utxo { /// The height of the block in which the UTXO was confirmed, or `0` if unconfirmed. pub height: absolute::Height, @@ -202,25 +231,31 @@ pub struct Utxo { pub txid: bitcoin::Txid, /// The value of the UTXO in satoshis. - #[serde(deserialize_with = "crate::custom_serde::amount_from_sats")] + #[serde( + deserialize_with = "crate::custom_serde::amount_from_sats", + serialize_with = "crate::custom_serde::amount_to_sats" + )] pub value: bitcoin::Amount, } /// Response to the `"blockchain.transaction.get"` method. /// /// Contains the full deserialized transaction. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] pub struct FullTx { /// The full transaction. - #[serde(deserialize_with = "crate::custom_serde::from_consensus_hex")] + #[serde( + deserialize_with = "crate::custom_serde::from_consensus_hex", + serialize_with = "crate::custom_serde::to_consensus_hex" + )] pub tx: bitcoin::Transaction, } /// Response to the `"blockchain.transaction.get_merkle"` method. /// /// Contains a Merkle proof of inclusion in a block. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct TxMerkle { /// The height of the block containing the transaction. pub block_height: absolute::Height, @@ -260,7 +295,7 @@ impl TxMerkle { /// Response to the `"blockchain.transaction.id_from_pos"` method. /// /// Returns the transaction ID at the given position in a block. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(transparent)] pub struct TxidFromPos { /// The transaction ID located at the specified position. @@ -270,19 +305,25 @@ pub struct TxidFromPos { /// Response entry from the `"mempool.get_fee_histogram"` method. /// /// Describes one fee-rate bin and the total weight of transactions at or above that rate. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct FeePair { /// The minimum fee rate (in sat/vB) for this bucket. - #[serde(deserialize_with = "crate::custom_serde::feerate_from_sat_per_byte")] + #[serde( + deserialize_with = "crate::custom_serde::feerate_from_sat_per_byte", + serialize_with = "crate::custom_serde::feerate_to_sat_per_byte" + )] pub fee_rate: bitcoin::FeeRate, /// The total weight (in vbytes) of transactions at or above this fee rate. - #[serde(deserialize_with = "crate::custom_serde::weight_from_vb")] + #[serde( + deserialize_with = "crate::custom_serde::weight_from_vb", + serialize_with = "crate::custom_serde::weight_to_vb" + )] pub weight: bitcoin::Weight, } /// Response to the `"server.features"` method. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ServerFeatures { /// Hosts. pub hosts: HashMap, @@ -311,10 +352,192 @@ pub struct ServerFeatures { } /// Server host values. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ServerHostValues { /// SSL Port. pub ssl_port: Option, /// TCP Port. pub tcp_port: Option, } + +#[cfg(test)] +mod test { + use super::*; + + /// Serializes `value`, deserializes the result back, and checks that nothing was lost. + /// + /// This is what guarantees that a serialized response is still valid Electrum wire data. + fn assert_round_trip(value: T) + where + T: serde::Serialize + serde::de::DeserializeOwned + core::fmt::Debug + PartialEq, + { + let json = serde_json::to_value(&value).expect("must serialize"); + let got = serde_json::from_value::(json.clone()).expect("must deserialize: {json}"); + assert_eq!(got, value, "round trip must be lossless: {json}"); + } + + fn header() -> bitcoin::block::Header { + bitcoin::block::Header { + version: bitcoin::block::Version::ONE, + prev_blockhash: BlockHash::all_zeros(), + merkle_root: bitcoin::TxMerkleNode::all_zeros(), + time: 1_231_006_505, + bits: bitcoin::CompactTarget::from_consensus(0x1d00_ffff), + nonce: 2_083_236_893, + } + } + + fn txid() -> bitcoin::Txid { + "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + .parse() + .expect("must parse") + } + + fn height(h: u32) -> absolute::Height { + absolute::Height::from_consensus(h).expect("must be a valid height") + } + + #[test] + fn round_trip_responses() { + assert_round_trip(HeaderResp { header: header() }); + assert_round_trip(HeaderWithProofResp { + branch: vec![DoubleSHA::hash(b"branch")], + header: header(), + root: DoubleSHA::hash(b"root"), + }); + assert_round_trip(HeadersResp { + count: 2, + headers: vec![header(), header()], + max: 2016, + }); + assert_round_trip(HeadersWithCheckpointResp { + count: 1, + headers: vec![header()], + max: 2016, + root: DoubleSHA::hash(b"root"), + branch: vec![DoubleSHA::hash(b"branch")], + }); + // 25_000 sat/kwu is 0.001 BTC/kvB, which is exactly representable as an `f32`. + assert_round_trip(EstimateFeeResp { + fee_rate: Some(bitcoin::FeeRate::from_sat_per_kwu(25_000)), + }); + assert_round_trip(EstimateFeeResp { fee_rate: None }); + assert_round_trip(HeadersSubscribeResp { + header: header(), + height: 840_000, + }); + assert_round_trip(RelayFeeResp { + fee: Amount::from_sat(1_000), + }); + assert_round_trip(GetBalanceResp { + confirmed: Amount::from_sat(123_456), + unconfirmed: Amount::from_sat(500), + }); + assert_round_trip(ConfirmedTx { + txid: txid(), + height: height(840_000), + }); + assert_round_trip(MempoolTx { + txid: txid(), + fee: Amount::from_sat(1_500), + confirmed_inputs: true, + }); + assert_round_trip(Utxo { + height: height(840_000), + tx_pos: 1, + txid: txid(), + value: Amount::from_sat(50_000), + }); + assert_round_trip(FullTx { + tx: bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }, + }); + assert_round_trip(TxMerkle { + block_height: height(840_000), + merkle: vec![DoubleSHA::hash(b"merkle")], + pos: 3, + }); + assert_round_trip(TxidFromPos { txid: txid() }); + // 250 sat/kwu is 1 sat/vB and 4000 WU is 1000 vB, so neither conversion loses precision. + assert_round_trip(FeePair { + fee_rate: bitcoin::FeeRate::from_sat_per_kwu(250), + weight: bitcoin::Weight::from_vb(1_000).expect("must not overflow"), + }); + assert_round_trip(ServerHostValues { + ssl_port: Some(50002), + tcp_port: None, + }); + assert_round_trip(ServerFeatures { + hosts: [( + "electrum.example.com".to_string(), + ServerHostValues { + ssl_port: Some(50002), + tcp_port: Some(50001), + }, + )] + .into_iter() + .collect(), + genesis_hash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + .parse() + .expect("must parse"), + hash_function: "sha256".to_string(), + server_version: "ElectrumX 1.16.0".to_string(), + protocol_max: "1.4".to_string(), + protocol_min: "1.4".to_string(), + pruning: None, + }); + } + + /// `Tx` is untagged, so a round trip must land back on the same variant. + #[test] + fn round_trip_tx_preserves_variant() { + for tx in [ + Tx::Mempool(MempoolTx { + txid: txid(), + fee: Amount::from_sat(1_500), + confirmed_inputs: true, + }), + Tx::Mempool(MempoolTx { + txid: txid(), + fee: Amount::from_sat(1_500), + confirmed_inputs: false, + }), + Tx::Confirmed(ConfirmedTx { + txid: txid(), + height: height(840_000), + }), + ] { + assert_round_trip(tx); + } + } + + /// `MempoolTx::confirmed_inputs` must go back out as the Electrum `height` field. + #[test] + fn mempool_tx_serializes_height_not_bool() { + let tx = MempoolTx { + txid: txid(), + fee: Amount::from_sat(1_500), + confirmed_inputs: true, + }; + assert_eq!( + serde_json::to_value(&tx).expect("must serialize"), + serde_json::json!({ + "tx_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + "fee": 1_500, + "height": 0, + }), + ); + assert_eq!( + serde_json::to_value(MempoolTx { + confirmed_inputs: false, + ..tx + }) + .expect("must serialize")["height"], + serde_json::json!(-1), + ); + } +}